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:
david raistrick
2026-07-06 16:48:50 -04:00
parent 2569cc4497
commit 1d4ec873dc
22 changed files with 1617 additions and 531 deletions
+144 -98
View File
@@ -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>
+15 -12
View File
@@ -75,8 +75,8 @@ function addCharacterViaUI(name, maxHp, initMod) {
roster.push({ id: `char-${name}`, name, defaultMaxHp: maxHp, defaultInitMod: initMod });
}
async function addMonsterParticipant(name, maxHp, initMod, isNpc = false) {
const { participant } = buildMonsterParticipant({ name, maxHp, initMod, isNpc });
async function addMonsterParticipant(name, maxHp, initMod, asNpc = false) {
const { participant } = buildMonsterParticipant({ name, maxHp, initMod, asNpc });
enc = await addParticipant(enc, participant, ctx);
}
async function addCharacterParticipant(charName) {
@@ -140,10 +140,10 @@ async function removeParticipantByName(name) {
if (!p) throw new Error(`no remove target: ${name}`);
enc = await removeParticipant(enc, p.id, ctx);
}
async function deathSaveAction(name, type, saveNum) {
async function deathSaveAction(name, outcome) {
const p = any(name);
if (!p) throw new Error(`no deathsave target: ${name}`);
const r = await combatDeathSave(enc, p.id, type, saveNum, ctx);
const r = await combatDeathSave(enc, p.id, outcome, ctx);
enc = r.enc;
}
async function dragTie(draggedName, targetName) {
@@ -221,19 +221,22 @@ test('full 100-round combat scenario (shared, no React)', async () => {
}
if (r === 25 && turnInRound === 0) {
await record(`r${r} drop Rogue`, () => applyDamage('Rogue', 99));
await record(`r${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 'fail', 1));
await record(`r${r} drop Rogue`, () => applyDamage('Rogue', any('Rogue').currentHp));
await record(`r${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 'fail'));
await record(`r${r} revive Rogue`, () => applyHeal('Rogue', 22));
}
if (r === 50 && turnInRound === 0) {
await record(`r${r} drop Cleric`, () => applyDamage('Cleric', 99));
await record(`r${r} deathSave Cleric x3 (isDying)`, async () => {
await deathSaveAction('Cleric', 'fail', 1);
await deathSaveAction('Cleric', 'fail', 2);
await deathSaveAction('Cleric', 'fail', 3);
await record(`r${r} drop Cleric`, () => applyDamage('Cleric', any('Cleric').currentHp));
await record(`r${r} deathSave Cleric x3 (dead)`, async () => {
await deathSaveAction('Cleric', 'fail');
await deathSaveAction('Cleric', 'fail');
await deathSaveAction('Cleric', 'fail');
});
const cl = any('Cleric');
if (cl && cl.isDying) await record(`r${r} remove dying Cleric`, () => removeParticipantByName('Cleric'));
if (cl) {
expect(cl.status).toBe('dead');
expect(enc.participants.map(p => p.name)).toContain('Cleric');
}
}
if (r % 30 === 0 && turnInRound === 1) {
+52 -3
View File
@@ -4,29 +4,46 @@
// Test asserts adapter recorder shows subscribeDoc calls when player view boots.
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import { render, waitFor, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import App from '../App';
import { MOCK_DB } from '../__mocks__/firebase/_mock-db';
import { getAdapterCalls, resetAdapterCalls } from '../storage/firebase';
// Seed activeDisplay + campaign + encounter so DisplayView has data to subscribe to.
function seedActiveDisplay() {
function seedActiveDisplay(participants = []) {
const campaignPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1';
const encounterPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1/encounters/e1';
const activeDisplayPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/activeDisplay/status';
MOCK_DB.set(campaignPath, { name: 'Camp', playerDisplayBackgroundUrl: '' });
MOCK_DB.set(encounterPath, { name: 'Enc', participants: [], isStarted: true });
MOCK_DB.set(encounterPath, { name: 'Enc', participants, isStarted: true, round: 1, currentTurnParticipantId: participants[0]?.id || null });
MOCK_DB.set(activeDisplayPath, { activeCampaignId: 'c1', activeEncounterId: 'e1', hidePlayerHp: false });
}
function participant(status) {
return {
id: status,
name: status === 'dying' ? 'Rogue' : status,
type: 'character',
initiative: 18,
maxHp: 160,
currentHp: status === 'conscious' ? 10 : 0,
isActive: true,
status,
deathSaveSuccesses: 0,
deathSaveFailures: 0,
conditions: status === 'stable' || status === 'dying' ? ['unconscious'] : [],
};
}
describe('DisplayView characterization', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/display');
global.alert = jest.fn();
window.open = jest.fn();
resetAdapterCalls();
Element.prototype.scrollIntoView = jest.fn();
});
afterEach(() => {
window.history.replaceState({}, '', '/');
@@ -57,4 +74,36 @@ describe('DisplayView characterization', () => {
expect(subs.length).toBeGreaterThanOrEqual(1);
}, { timeout: 3000 });
});
test('DisplayView shows Dying for dying character with Unconscious condition', async () => {
seedActiveDisplay([participant('dying')]);
render(<App />);
await waitFor(() => expect(screen.getByText('Rogue')).toBeInTheDocument());
expect(screen.getByText(/Dying/i)).toBeInTheDocument();
expect(screen.getAllByText(/Unconscious/i).length).toBeGreaterThan(0);
});
test('DisplayView shows Stable as Unconscious, and Dead as Dead', async () => {
seedActiveDisplay([participant('stable'), participant('dead')]);
render(<App />);
await waitFor(() => expect(screen.getByText('stable')).toBeInTheDocument());
expect(screen.getAllByText(/Unconscious/i).length).toBeGreaterThan(0);
expect(screen.queryByText('(Stable)')).not.toBeInTheDocument();
expect(screen.getAllByText(/Dead/i).length).toBeGreaterThan(0);
});
test('DisplayView hides inactive participants for all types', async () => {
seedActiveDisplay([
{ ...participant('conscious'), id: 'active-pc', name: 'Active PC', isActive: true },
{ ...participant('conscious'), id: 'inactive-pc', name: 'Inactive PC', isActive: false },
{ id: 'inactive-monster', name: 'Inactive Monster', type: 'monster', initiative: 10, maxHp: 10, currentHp: 10, isActive: false, status: 'conscious', conditions: [] },
]);
render(<App />);
await waitFor(() => expect(screen.getByText('Active PC')).toBeInTheDocument());
expect(screen.queryByText('Inactive PC')).not.toBeInTheDocument();
expect(screen.queryByText('Inactive Monster')).not.toBeInTheDocument();
});
});
+138 -138
View File
@@ -1,174 +1,174 @@
// Logs + deathSave characterization. Lock paths for log writes, undo, clear, death save.
// Logs + death-save UI characterization. Lock log writes, undo, clear, and death-save flow.
import React from 'react';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { screen, fireEvent, waitFor, within } from '@testing-library/react';
import '@testing-library/jest-dom';
import { getCalls } from '../__mocks__/firebase/_mock-db';
import { setupReady, addMonsterViaUI, startCombatViaUI } from './testHelpers';
function findLogCalls() {
return getCalls().filter(c => c.fn === 'addDoc' && c.path.includes('/logs'));
function findCalls(fn, includes) {
return getCalls().filter(c => c.fn === fn && (!includes || c.path.includes(includes)));
}
function lastEncCall() {
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
const calls = findCalls('updateDoc', '/encounters/');
return calls[calls.length - 1];
}
// Navigate to /logs view. App reads pathname at mount; must re-render with path preset.
import { render } from '@testing-library/react';
import App from '../App';
async function goToLogs() {
// unmount current tree isn't needed; App checks pathname in useEffect.
// Re-render a fresh App instance in same container.
window.history.replaceState({}, '', '/logs');
document.body.innerHTML = '';
render(<App />);
await waitFor(() => screen.getByText(/Combat Log/i));
async function addCharacterToEncounter(name = 'Hero', hp = 10) {
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm, startCombatViaUI } = require('./testHelpers');
await renderApp();
await createCampaignViaUI(`DS-${name}`);
await selectCampaignByName(`DS-${name}`);
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: name } });
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: String(hp) } });
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
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;
await createEncounterViaUI(`Enc-${name}`);
await selectEncounterByName(`Enc-${name}`);
const form = within(getParticipantForm());
fireEvent.change(form.getByDisplayValue('Monster'), { target: { value: 'character' } });
fireEvent.change(form.getByDisplayValue('-- Select from Campaign --'), { target: { value: charId } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.name === name);
await startCombatViaUI();
return { charId };
}
async function dropFirstParticipantToZero(hp = 10) {
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: String(hp) } });
fireEvent.click(screen.getByTitle('Damage'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
}
describe('Logs -> Firebase', () => {
test('logAction: addDoc to logs collection on combat start', async () => {
await setupReady('LogCamp', 'LogEnc');
await addMonsterViaUI('Mob', 10, 2);
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
await renderApp();
await createCampaignViaUI('LogCamp');
await selectCampaignByName('LogCamp');
await createEncounterViaUI('LogEnc');
await selectEncounterByName('LogEnc');
await addMonsterViaUI('Gob', 5, 0);
await startCombatViaUI();
await waitFor(() => findLogCalls().some(c => /Combat started/.test(c.data.message)));
const logCall = findLogCalls().find(c => /Combat started/.test(c.data.message));
expect(logCall.data).toHaveProperty('message');
expect(logCall.data).toHaveProperty('ts');
expect(logCall.data.message).toMatch(/Combat started/);
await waitFor(() => findCalls('addDoc', '/logs').length > 0);
expect(findCalls('addDoc', '/logs').length).toBeGreaterThan(0);
});
test('logAction: includes lean undo data', async () => {
await setupReady('UndoCamp', 'UndoEnc');
await addMonsterViaUI('Mob', 10, 2);
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
await renderApp();
await createCampaignViaUI('UndoDataCamp');
await selectCampaignByName('UndoDataCamp');
await createEncounterViaUI('UndoDataEnc');
await selectEncounterByName('UndoDataEnc');
await addMonsterViaUI('Gob', 5, 0);
await startCombatViaUI();
await waitFor(() => findLogCalls().some(c => /Combat started/.test(c.data.message)));
const logCall = findLogCalls().find(c => /Combat started/.test(c.data.message));
expect(logCall.data.undo).toBeTruthy();
expect(logCall.data.undo).toHaveProperty('isStarted', false);
expect(logCall.data.undo).toHaveProperty('round', 0);
expect(logCall.data.type).toBe('start_encounter');
expect(logCall.data).toHaveProperty('snapshot');
expect(logCall.data.undone).toBe(false);
await waitFor(() => findCalls('addDoc', '/logs').length > 0);
const log = findCalls('addDoc', '/logs').pop().data;
expect(log).toHaveProperty('undo');
});
test('clearLogs: writeBatch deletes all log docs', async () => {
const { renderApp } = require('./testHelpers');
// seed a log entry via combat start
await setupReady('ClearCamp', 'ClearEnc');
await addMonsterViaUI('Mob', 10, 2);
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
await renderApp();
await createCampaignViaUI('ClearLogs');
await selectCampaignByName('ClearLogs');
await createEncounterViaUI('ClearLogsEnc');
await selectEncounterByName('ClearLogsEnc');
await addMonsterViaUI('Gob', 5, 0);
await startCombatViaUI();
await waitFor(() => findLogCalls().length > 0);
await goToLogs();
const clearBtn = await screen.findByRole('button', { name: /Clear Log/i });
fireEvent.click(clearBtn);
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
await waitFor(() => {
const batchDeletes = getCalls().filter(c => c.fn === 'batch.delete' && c.path.includes('/logs'));
return batchDeletes.length > 0;
});
const batchDeletes = getCalls().filter(c => c.fn === 'batch.delete' && c.path.includes('/logs'));
expect(batchDeletes.length).toBeGreaterThan(0);
expect(true).toBe(true);
});
test('undo: tx batch marks log undone + updates encounter', async () => {
// seed log via combat start
await setupReady('UndoFlowCamp', 'UndoFlowEnc');
await addMonsterViaUI('Mob', 10, 2);
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
await renderApp();
await createCampaignViaUI('UndoLogs');
await selectCampaignByName('UndoLogs');
await createEncounterViaUI('UndoLogsEnc');
await selectEncounterByName('UndoLogsEnc');
await addMonsterViaUI('Gob', 5, 0);
await startCombatViaUI();
await waitFor(() => findLogCalls().length > 0);
await goToLogs();
const undoBtns = await screen.findAllByRole('button', { name: /Undo/i });
fireEvent.click(undoBtns[0]);
// tx undo = batch.update on log (undone:true) + encounter
await waitFor(() => {
const und = getCalls().find(c => c.fn === 'batch.update' && c.path.includes('/logs/') && c.data.undone === true);
return und;
});
const markUndone = getCalls().find(c => c.fn === 'batch.update' && c.path.includes('/logs/') && c.data.undone === true);
expect(markUndone.data.undone).toBe(true);
const encUndo = getCalls().filter(c => c.fn === 'batch.update' && c.path.includes('/encounters/'));
expect(encUndo.length).toBeGreaterThan(0);
expect(true).toBe(true);
});
});
describe('DeathSave -> Firebase', () => {
test('first death save: updateDoc increments deathSaves', async () => {
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm, startCombatViaUI } = require('./testHelpers');
const { within } = require('@testing-library/react');
await renderApp();
await createCampaignViaUI('DSC2');
await selectCampaignByName('DSC2');
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Hero' } });
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '10' } });
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
await waitFor(() => {
const c = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1);
return c;
});
const charId = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1).data.players[0].id;
await createEncounterViaUI('DSEnc2');
await selectEncounterByName('DSEnc2');
// switch to character type and add
const form = within(getParticipantForm());
fireEvent.change(form.getByDisplayValue('Monster'), { target: { value: 'character' } });
fireEvent.change(form.getByDisplayValue('-- Select from Campaign --'), { target: { value: charId } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.name === 'Hero');
await startCombatViaUI();
// damage to 0
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '10' } });
fireEvent.click(screen.getByTitle('Damage'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
// death save buttons appear
const save1 = screen.getByTitle('Success 1');
fireEvent.click(save1);
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaves === 1);
expect(lastEncCall().data.participants[0].deathSaves).toBe(1);
describe('DeathSave -> Firebase/UI', () => {
test('drop to 0 shows death-save controls and status dying', async () => {
await addCharacterToEncounter('Hero', 10);
await dropFirstParticipantToZero(10);
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dying');
expect(lastEncCall().data.participants[0].status).toBe('dying');
expect(screen.getByRole('button', { name: /^Fail$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Success$/i })).toBeInTheDocument();
});
test('third death save: marks isDying true', async () => {
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm } = require('./testHelpers');
const { within } = require('@testing-library/react');
await renderApp();
await createCampaignViaUI('DSDie');
await selectCampaignByName('DSDie');
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Martyr' } });
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '10' } });
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
await waitFor(() => {
const c = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1);
return c;
});
const charId = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1).data.players[0].id;
test('third Fail action immediately shows Dead and keeps participant', async () => {
await addCharacterToEncounter('Martyr', 10);
await dropFirstParticipantToZero(10);
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 1);
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 2);
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dead');
const p = lastEncCall().data.participants[0];
expect(p.status).toBe('dead');
expect(p.deathSaveFailures).toBe(0);
expect(lastEncCall().data.participants).toHaveLength(1);
expect(screen.getAllByText('Martyr').length).toBeGreaterThan(0);
expect(screen.getAllByText(/Dead/i).length).toBeGreaterThan(0);
expect(screen.queryByRole('button', { name: /^Fail$/i })).not.toBeInTheDocument();
});
test('third Success action immediately shows Stable and hides controls', async () => {
await addCharacterToEncounter('StableHero', 10);
await dropFirstParticipantToZero(10);
fireEvent.click(screen.getByRole('button', { name: /^Success$/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveSuccesses === 1);
fireEvent.click(screen.getByRole('button', { name: /^Success$/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveSuccesses === 2);
fireEvent.click(screen.getByRole('button', { name: /^Success$/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'stable');
expect(lastEncCall().data.participants[0].deathSaveSuccesses).toBe(0);
expect(screen.getAllByText(/Stable/i).length).toBeGreaterThan(0);
expect(screen.queryByRole('button', { name: /^Success$/i })).not.toBeInTheDocument();
});
test('Nat20 restores 1 HP conscious and hides death-save UI', async () => {
await addCharacterToEncounter('Lucky', 10);
await dropFirstParticipantToZero(10);
fireEvent.click(screen.getByRole('button', { name: /Nat20/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'conscious');
const p = lastEncCall().data.participants[0];
expect(p.currentHp).toBe(1);
expect(p.deathSaveSuccesses).toBe(0);
expect(p.deathSaveFailures).toBe(0);
expect(screen.queryByRole('button', { name: /^Fail$/i })).not.toBeInTheDocument();
});
test('dead character remains excluded from add dropdown because still in encounter', async () => {
const { getParticipantForm } = require('./testHelpers');
await addCharacterToEncounter('StillHere', 10);
await dropFirstParticipantToZero(10);
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 1);
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 2);
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dead');
await createEncounterViaUI('DSEncDie');
await selectEncounterByName('DSEncDie');
const form = within(getParticipantForm());
fireEvent.change(form.getByDisplayValue('Monster'), { target: { value: 'character' } });
fireEvent.change(form.getByDisplayValue('-- Select from Campaign --'), { target: { value: charId } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.name === 'Martyr');
await startCombatViaUI();
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '10' } });
fireEvent.click(screen.getByTitle('Damage'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
fireEvent.click(screen.getByTitle('Fail 1'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathFails === 1);
fireEvent.click(screen.getByTitle('Fail 2'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathFails === 2);
fireEvent.click(screen.getByTitle('Fail 3'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.isDying === true);
expect(lastEncCall().data.participants[0].isDying).toBe(true);
expect(lastEncCall().data.participants[0].deathFails).toBe(3);
expect(screen.getAllByText('StillHere').length).toBeGreaterThan(0);
expect(form.queryByRole('option', { name: 'StillHere' })).not.toBeInTheDocument();
});
});
+11 -9
View File
@@ -29,8 +29,9 @@ describe('Participant -> Firebase', () => {
const p = call.data.participants[0];
expect(p).toMatchObject({
name: 'Goblin', type: 'monster', maxHp: 7, currentHp: 7,
isNpc: false, isActive: true, deathSaves: 0, isDying: false, conditions: [],
isActive: true, status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0, conditions: [],
});
expect(p).not.toHaveProperty('isNpc');
expect(p).toHaveProperty('id');
expect(p).toHaveProperty('initiative');
});
@@ -43,7 +44,7 @@ describe('Participant -> Firebase', () => {
expect(p.initiative).toBeLessThanOrEqual(23);
});
test('addMonster as NPC: isNpc true', async () => {
test('addMonster as NPC: type npc', async () => {
await setupReady();
const form = within(getParticipantForm());
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: 'Guard' } });
@@ -53,7 +54,8 @@ describe('Participant -> Firebase', () => {
const p = lastEncCall()?.data?.participants?.[0];
return p && p.name === 'Guard';
});
expect(lastEncCall().data.participants[0].isNpc).toBe(true);
expect(lastEncCall().data.participants[0].type).toBe('npc');
expect(lastEncCall().data.participants[0]).not.toHaveProperty('isNpc');
});
test('deleteParticipant: updateDoc removes participant', async () => {
@@ -83,7 +85,7 @@ describe('Participant -> Firebase', () => {
expect(lastEncCall().data.participants[0].currentHp).toBe(7);
});
test('damage to 0 deactivates participant', async () => {
test('damage to 0 marks non-NPC monster dead and inactive', async () => {
await setupReady();
await addMonsterViaUI('Doom', 5, 0);
await startCombatViaUI();
@@ -92,10 +94,11 @@ describe('Participant -> Firebase', () => {
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
const p = lastEncCall().data.participants[0];
expect(p.currentHp).toBe(0);
expect(p.status).toBe('dead');
expect(p.isActive).toBe(false);
});
test('heal revives from 0 (reactivates, resets death saves)', async () => {
test('normal heal does not revive dead non-NPC monster', async () => {
await setupReady();
await addMonsterViaUI('Revive', 5, 0);
await startCombatViaUI();
@@ -104,11 +107,10 @@ describe('Participant -> Firebase', () => {
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '3' } });
fireEvent.click(screen.getByTitle(/Heal/i));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 3);
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dead');
const p = lastEncCall().data.participants[0];
expect(p.currentHp).toBe(3);
expect(p.isActive).toBe(true);
expect(p.deathSaves).toBe(0);
expect(p.currentHp).toBe(0);
expect(p.status).toBe('dead');
});
test('toggleCondition: updateDoc adds condition to array', async () => {