feat: Add PF2e dying and death system
All checks were successful
Deploy Dimension47 / deploy (push) Successful in 36s

- Add Dying condition when HP drops to 0 (value = 1 + Wounded)
- Recovery check modal with manual outcome selection (Crit Success/Success/Failure/Crit Failure)
- Dying indicator replaces HP display when character is dying
- Death overlay with revive button when Dying reaches threshold (4 - Doomed)
- Revive removes Dying/Wounded/Doomed and sets HP to 1
- Real-time sync via WebSocket for all dying state changes
- Automatic Wounded condition when recovering from dying

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alexander Zielonka
2026-01-21 14:39:21 +01:00
parent 7bc55566d8
commit eb5b7fdf05
9 changed files with 1253 additions and 8 deletions

View File

@@ -40,6 +40,7 @@ import { FeatDetailModal } from './feat-detail-modal';
import { ActionsTab } from './actions-tab';
import { RestModal } from './rest-modal';
import { AlchemyTab } from './alchemy-tab';
import { RecoveryCheckModal } from './recovery-check-modal';
import { useCharacterSocket } from '@/shared/hooks/use-character-socket';
import { downloadCharacterHTML } from '../utils/export-character-html';
import type { Character, CharacterItem, CharacterFeat, Campaign } from '@/shared/types';
@@ -128,6 +129,7 @@ export function CharacterSheetPage() {
const [editingCredits, setEditingCredits] = useState(false);
const [creditsInput, setCreditsInput] = useState('');
const [showRestModal, setShowRestModal] = useState(false);
const [showRecoveryCheckModal, setShowRecoveryCheckModal] = useState(false);
const isOwner = character?.ownerId === user?.id;
const isGM = campaign?.gmId === user?.id;
@@ -154,7 +156,7 @@ export function CharacterSheetPage() {
}, [campaignId, characterId]);
// WebSocket connection for real-time sync
useCharacterSocket({
const { socket } = useCharacterSocket({
characterId: characterId || '',
onHpUpdate: (data) => {
setCharacter((prev) => prev ? { ...prev, hpCurrent: data.hpCurrent, hpTemp: data.hpTemp, hpMax: data.hpMax } : null);
@@ -303,6 +305,16 @@ export function CharacterSheetPage() {
onAlchemyStateUpdate: (data) => {
setCharacter((prev) => prev ? { ...prev, alchemyState: data } : prev);
},
onDyingUpdate: (data) => {
setCharacter((prev) => {
if (!prev) return null;
// Update conditions from the dying update
if (data.conditions) {
return { ...prev, conditions: data.conditions };
}
return prev;
});
},
onFullUpdate: (updatedCharacter) => {
setCharacter(updatedCharacter);
},
@@ -311,8 +323,10 @@ export function CharacterSheetPage() {
const handleHpChange = async (newHp: number) => {
if (!character || !campaignId) return;
const clampedHp = Math.max(0, Math.min(character.hpMax, newHp));
// Optimistic update for HP
setCharacter((prev) => prev ? { ...prev, hpCurrent: clampedHp } : null);
await api.updateCharacterHp(campaignId, character.id, clampedHp);
setCharacter({ ...character, hpCurrent: clampedHp });
// WebSocket will handle dying/conditions updates
};
const handleDelete = async () => {
@@ -499,6 +513,24 @@ export function CharacterSheetPage() {
);
}
// Helper to extract dying-related values from conditions
const getDyingState = () => {
const dyingCondition = character?.conditions.find(
(c) => c.name.toLowerCase() === 'dying' || c.nameGerman?.toLowerCase() === 'sterbend'
);
const woundedCondition = character?.conditions.find(
(c) => c.name.toLowerCase() === 'wounded' || c.nameGerman?.toLowerCase() === 'verwundet'
);
const doomedCondition = character?.conditions.find(
(c) => c.name.toLowerCase() === 'doomed' || c.nameGerman?.toLowerCase() === 'verdammt'
);
return {
dyingValue: dyingCondition?.value || 0,
woundedValue: woundedCondition?.value || 0,
doomedValue: doomedCondition?.value || 0,
};
};
// Tab Content Renderers
const renderStatusTab = () => {
// AC Berechnung
@@ -601,6 +633,8 @@ export function CharacterSheetPage() {
} | undefined;
const speed = pbSpeed?.build?.attributes?.speed || 25;
const { dyingValue, woundedValue, doomedValue } = getDyingState();
return (
<div className="space-y-4">
{/* HP Row - Full width on mobile */}
@@ -608,7 +642,15 @@ export function CharacterSheetPage() {
hpCurrent={character.hpCurrent}
hpMax={character.hpMax}
hpTemp={character.hpTemp}
dyingValue={dyingValue}
woundedValue={woundedValue}
doomedValue={doomedValue}
onHpChange={handleHpChange}
onRecoveryCheck={() => setShowRecoveryCheckModal(true)}
onRevive={() => {
if (!socket) return;
socket.emit('revive_character', { characterId: character.id });
}}
/>
{/* AC & Speed Row */}
@@ -1670,6 +1712,32 @@ export function CharacterSheetPage() {
}}
/>
)}
{showRecoveryCheckModal && character && getDyingState().dyingValue > 0 && (
<RecoveryCheckModal
dyingValue={getDyingState().dyingValue}
doomedValue={getDyingState().doomedValue}
onClose={() => setShowRecoveryCheckModal(false)}
onRoll={async (dieRoll) => {
return new Promise((resolve, reject) => {
if (!socket) {
reject(new Error('Not connected'));
return;
}
socket.emit(
'recovery_check',
{ characterId: character.id, dieRoll },
(response: { success: boolean; result?: any; error?: string }) => {
if (response.success && response.result) {
resolve(response.result);
} else {
reject(new Error(response.error || 'Recovery check failed'));
}
}
);
});
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,105 @@
import { Skull, Heart, AlertTriangle, Dices } from 'lucide-react';
interface DyingIndicatorProps {
dyingValue: number;
woundedValue: number;
doomedValue: number;
onRecoveryCheck?: () => void;
}
export function DyingIndicator({
dyingValue,
woundedValue,
doomedValue,
onRecoveryCheck,
}: DyingIndicatorProps) {
const deathThreshold = 4 - doomedValue;
const isDying = dyingValue > 0;
const isNearDeath = dyingValue >= deathThreshold - 1;
if (!isDying && woundedValue === 0 && doomedValue === 0) {
return null;
}
return (
<div className="space-y-3">
{/* Dying Status */}
{isDying && (
<div
className={`p-4 rounded-xl border-2 ${
isNearDeath
? 'bg-red-500/20 border-red-500/50'
: 'bg-orange-500/20 border-orange-500/50'
}`}
>
{/* Header with skull and title */}
<div className="flex items-center justify-center gap-3 mb-4">
<Skull className={`h-8 w-8 ${isNearDeath ? 'text-red-400' : 'text-orange-400'}`} />
<span className={`text-2xl font-bold ${isNearDeath ? 'text-red-400' : 'text-orange-400'}`}>
Sterbend
</span>
</div>
{/* Dying value indicators - larger */}
<div className="flex items-center justify-center gap-2 mb-4">
{Array.from({ length: deathThreshold }).map((_, i) => (
<div
key={i}
className={`w-8 h-8 rounded-full border-2 flex items-center justify-center ${
i < dyingValue
? 'bg-red-500 border-red-400'
: 'bg-transparent border-gray-500'
}`}
>
{i < dyingValue && <Skull className="h-4 w-4 text-white" />}
</div>
))}
</div>
{/* Info row */}
<div className="flex items-center justify-between text-sm mb-4">
<span className="text-text-secondary">
Genesungswurf SG <span className="font-bold text-text-primary">{10 + dyingValue}</span>
</span>
<span className={isNearDeath ? 'text-red-400 font-semibold' : 'text-text-secondary'}>
Tod bei {deathThreshold}
</span>
</div>
{/* Recovery Check Button - Prominent */}
{onRecoveryCheck && (
<button
onClick={onRecoveryCheck}
className={`w-full py-4 px-4 rounded-xl font-semibold text-lg transition-all flex items-center justify-center gap-2 ${
isNearDeath
? 'bg-red-500/40 hover:bg-red-500/60 text-red-200 border border-red-500/50'
: 'bg-orange-500/40 hover:bg-orange-500/60 text-orange-200 border border-orange-500/50'
}`}
>
<Dices className="h-6 w-6" />
Genesungswurf
</button>
)}
</div>
)}
{/* Wounded & Doomed Status Bar */}
{(woundedValue > 0 || doomedValue > 0) && (
<div className="flex gap-2 flex-wrap">
{woundedValue > 0 && (
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-yellow-500/20 text-yellow-400 text-sm">
<Heart className="h-4 w-4" />
<span className="font-medium">Verwundet {woundedValue}</span>
</div>
)}
{doomedValue > 0 && (
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-purple-500/20 text-purple-400 text-sm">
<AlertTriangle className="h-4 w-4" />
<span className="font-medium">Verdammt {doomedValue}</span>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,17 +1,33 @@
import { useState } from 'react';
import { Heart, Swords, Sparkles, X } from 'lucide-react';
import { Heart, Swords, Sparkles, X, Skull, RotateCcw } from 'lucide-react';
import { Button, Card, CardContent, Input } from '@/shared/components/ui';
import { DyingIndicator } from './dying-indicator';
interface HpControlProps {
hpCurrent: number;
hpMax: number;
hpTemp?: number;
dyingValue?: number;
woundedValue?: number;
doomedValue?: number;
onHpChange: (newHp: number) => Promise<void>;
onRecoveryCheck?: () => void;
onRevive?: () => void;
}
type Mode = 'view' | 'damage' | 'heal' | 'direct';
export function HpControl({ hpCurrent, hpMax, hpTemp = 0, onHpChange }: HpControlProps) {
export function HpControl({
hpCurrent,
hpMax,
hpTemp = 0,
dyingValue = 0,
woundedValue = 0,
doomedValue = 0,
onHpChange,
onRecoveryCheck,
onRevive,
}: HpControlProps) {
const [mode, setMode] = useState<Mode>('view');
const [pendingChange, setPendingChange] = useState(0);
const [directValue, setDirectValue] = useState(hpCurrent);
@@ -85,6 +101,73 @@ export function HpControl({ hpCurrent, hpMax, hpTemp = 0, onHpChange }: HpContro
// View Mode - Main Display
if (mode === 'view') {
const deathThreshold = 4 - doomedValue;
const isDead = dyingValue >= deathThreshold;
const isDying = dyingValue > 0 && !isDead;
// When dead, show death overlay
if (isDead) {
return (
<Card className="border-gray-500/50 bg-gray-900/50">
<CardContent className="py-8">
<div className="flex flex-col items-center text-center">
{/* Death Icon */}
<div className="w-20 h-20 rounded-full bg-gray-800 flex items-center justify-center mb-4 border-2 border-gray-600">
<Skull className="h-10 w-10 text-gray-400" />
</div>
{/* Death Text */}
<h3 className="text-2xl font-bold text-gray-400 mb-2">Tot</h3>
<p className="text-sm text-gray-500 mb-6">
Dieser Charakter ist gestorben.
</p>
{/* Revive Button */}
{onRevive && (
<Button
variant="outline"
className="h-14 px-8 text-base font-semibold border-primary-500/50 text-primary-400 hover:bg-primary-500/20 hover:border-primary-500"
onClick={onRevive}
>
<RotateCcw className="h-5 w-5 mr-2" />
Wiederbeleben
</Button>
)}
</div>
</CardContent>
</Card>
);
}
// When dying, show a completely different view focused on recovery
if (isDying) {
return (
<Card className="border-red-500/30">
<CardContent className="py-4">
{/* Dying State - Full Width */}
<DyingIndicator
dyingValue={dyingValue}
woundedValue={woundedValue}
doomedValue={doomedValue}
onRecoveryCheck={onRecoveryCheck}
/>
{/* Heal Button - Primary action when dying */}
<div className="mt-4">
<Button
variant="outline"
className="w-full h-14 text-base font-semibold border-green-500/50 text-green-400 hover:bg-green-500/20 hover:border-green-500"
onClick={() => openMode('heal')}
>
<Sparkles className="h-5 w-5 mr-2" />
Heilung erhalten
</Button>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardContent className="py-4">
@@ -113,6 +196,17 @@ export function HpControl({ hpCurrent, hpMax, hpTemp = 0, onHpChange }: HpContro
</div>
</div>
{/* Wounded/Doomed indicators (when not dying) */}
{(woundedValue > 0 || doomedValue > 0) && (
<div className="mb-3">
<DyingIndicator
dyingValue={0}
woundedValue={woundedValue}
doomedValue={doomedValue}
/>
</div>
)}
{/* Quick Action Buttons */}
<div className="grid grid-cols-2 gap-3">
<Button

View File

@@ -0,0 +1,239 @@
import { useState } from 'react';
import { X, Skull, Heart, Check, AlertTriangle } from 'lucide-react';
import { Button } from '@/shared/components/ui';
type OutcomeType = 'critical_success' | 'success' | 'failure' | 'critical_failure';
interface RecoveryCheckResult {
result: OutcomeType;
dieRoll: number;
dc: number;
newDyingValue: number;
stabilized: boolean;
died: boolean;
message: string;
woundedAdded: boolean;
}
interface RecoveryCheckModalProps {
dyingValue: number;
doomedValue: number;
onClose: () => void;
onRoll: (dieRoll: number) => Promise<RecoveryCheckResult>;
}
export function RecoveryCheckModal({
dyingValue,
doomedValue,
onClose,
onRoll,
}: RecoveryCheckModalProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [result, setResult] = useState<RecoveryCheckResult | null>(null);
const dc = 10 + dyingValue;
const deathThreshold = 4 - doomedValue;
// Map outcome to a fake die roll that produces that outcome
const getSimulatedRoll = (outcome: OutcomeType): number => {
switch (outcome) {
case 'critical_success':
return 20; // Natural 20 = crit success
case 'success':
return dc; // Exactly DC = success
case 'failure':
return dc - 1; // Below DC = failure
case 'critical_failure':
return 1; // Natural 1 = crit failure
}
};
const handleOutcome = async (outcome: OutcomeType) => {
setIsSubmitting(true);
try {
const simulatedRoll = getSimulatedRoll(outcome);
const res = await onRoll(simulatedRoll);
setResult(res);
} catch (error) {
console.error('Recovery check failed:', error);
} finally {
setIsSubmitting(false);
}
};
const getResultColor = (res: OutcomeType) => {
switch (res) {
case 'critical_success':
return 'text-green-400';
case 'success':
return 'text-blue-400';
case 'failure':
return 'text-orange-400';
case 'critical_failure':
return 'text-red-400';
}
};
const getResultName = (res: OutcomeType) => {
switch (res) {
case 'critical_success':
return 'Kritischer Erfolg!';
case 'success':
return 'Erfolg!';
case 'failure':
return 'Fehlschlag!';
case 'critical_failure':
return 'Kritischer Fehlschlag!';
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/70" onClick={onClose} />
{/* Modal */}
<div className="relative w-full max-w-sm mx-4 bg-bg-secondary rounded-2xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border bg-red-500/10">
<div className="flex items-center gap-2">
<Skull className="h-5 w-5 text-red-400" />
<h2 className="text-lg font-semibold text-red-400">Genesungswurf</h2>
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-5 w-5" />
</Button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Dying Status */}
<div className="flex items-center justify-between p-3 rounded-lg bg-bg-tertiary">
<div className="flex items-center gap-2">
<Skull className="h-5 w-5 text-orange-400" />
<span className="font-medium text-text-primary">Sterbend {dyingValue}</span>
</div>
<div className="flex items-center gap-1">
{Array.from({ length: deathThreshold }).map((_, i) => (
<div
key={i}
className={`w-3 h-3 rounded-full border ${
i < dyingValue
? 'bg-red-500 border-red-400'
: 'bg-transparent border-gray-500'
}`}
/>
))}
</div>
</div>
{/* DC Display */}
<div className="text-center py-2">
<p className="text-sm text-text-secondary">Schwierigkeitsgrad</p>
<p className="text-5xl font-bold text-text-primary">{dc}</p>
</div>
{/* Result Display */}
{result && (
<div
className={`p-4 rounded-lg ${
result.died
? 'bg-red-500/20 border border-red-500/50'
: result.stabilized
? 'bg-green-500/20 border border-green-500/50'
: result.result === 'failure' || result.result === 'critical_failure'
? 'bg-orange-500/20 border border-orange-500/50'
: 'bg-blue-500/20 border border-blue-500/50'
}`}
>
<div className="flex items-center justify-center gap-2 mb-2">
{result.died ? (
<Skull className="h-6 w-6 text-red-400" />
) : result.stabilized ? (
<Heart className="h-6 w-6 text-green-400" />
) : result.result === 'critical_success' || result.result === 'success' ? (
<Check className="h-6 w-6" />
) : (
<AlertTriangle className="h-6 w-6" />
)}
<span className={`font-bold text-lg ${getResultColor(result.result)}`}>
{getResultName(result.result)}
</span>
</div>
<p className="text-sm text-text-primary text-center">{result.message}</p>
{result.woundedAdded && !result.died && (
<p className="text-xs text-yellow-400 mt-2 flex items-center justify-center gap-1">
<Heart className="h-3 w-3" />
Verwundet hinzugefugt
</p>
)}
</div>
)}
{/* Outcome Buttons - Only show if no result yet */}
{!result && (
<div className="space-y-3">
<p className="text-sm text-text-secondary text-center">Ergebnis des Wurfs:</p>
{/* Success buttons */}
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => handleOutcome('critical_success')}
disabled={isSubmitting}
className="py-4 rounded-xl bg-green-500/20 hover:bg-green-500/30 border border-green-500/50 text-green-400 font-semibold transition-colors disabled:opacity-50 flex flex-col items-center gap-1"
>
<Check className="h-6 w-6" />
<span>Krit. Erfolg</span>
<span className="text-xs opacity-70">Sterbend -2</span>
</button>
<button
onClick={() => handleOutcome('success')}
disabled={isSubmitting}
className="py-4 rounded-xl bg-blue-500/20 hover:bg-blue-500/30 border border-blue-500/50 text-blue-400 font-semibold transition-colors disabled:opacity-50 flex flex-col items-center gap-1"
>
<Check className="h-5 w-5" />
<span>Erfolg</span>
<span className="text-xs opacity-70">Sterbend -1</span>
</button>
</div>
{/* Failure buttons */}
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => handleOutcome('failure')}
disabled={isSubmitting}
className="py-4 rounded-xl bg-orange-500/20 hover:bg-orange-500/30 border border-orange-500/50 text-orange-400 font-semibold transition-colors disabled:opacity-50 flex flex-col items-center gap-1"
>
<AlertTriangle className="h-5 w-5" />
<span>Fehlschlag</span>
<span className="text-xs opacity-70">Sterbend +1</span>
</button>
<button
onClick={() => handleOutcome('critical_failure')}
disabled={isSubmitting}
className="py-4 rounded-xl bg-red-500/20 hover:bg-red-500/30 border border-red-500/50 text-red-400 font-semibold transition-colors disabled:opacity-50 flex flex-col items-center gap-1"
>
<Skull className="h-6 w-6" />
<span>Krit. Fehlschlag</span>
<span className="text-xs opacity-70">Sterbend +2</span>
</button>
</div>
</div>
)}
{/* Close Button (after result) */}
{result && (
<Button
className="w-full h-12"
variant="outline"
onClick={onClose}
>
Schliessen
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -12,7 +12,7 @@ let globalSocketRefCount = 0;
let currentCharacterId: string | null = null;
let connectionAttempted = false;
export type CharacterUpdateType = 'hp' | 'conditions' | 'item' | 'inventory' | 'money' | 'level' | 'equipment_status' | 'rest' | 'alchemy_vials' | 'alchemy_formulas' | 'alchemy_prepared' | 'alchemy_state';
export type CharacterUpdateType = 'hp' | 'conditions' | 'item' | 'inventory' | 'money' | 'level' | 'equipment_status' | 'rest' | 'alchemy_vials' | 'alchemy_formulas' | 'alchemy_prepared' | 'alchemy_state' | 'dying';
export interface CharacterUpdate {
characterId: string;
@@ -29,6 +29,19 @@ export interface RestUpdateData {
alchemyReset: boolean;
}
export interface DyingUpdateData {
action: 'entered_dying' | 'dying_increased' | 'recovery_check' | 'healed_from_dying';
dyingValue?: number;
woundedValue?: number;
died?: boolean;
stabilized?: boolean;
result?: 'critical_success' | 'success' | 'failure' | 'critical_failure';
dieRoll?: number;
dc?: number;
woundedAdded?: boolean;
conditions?: CharacterCondition[];
}
interface UseCharacterSocketOptions {
characterId: string;
onHpUpdate?: (data: { hpCurrent: number; hpTemp: number; hpMax: number }) => void;
@@ -42,6 +55,7 @@ interface UseCharacterSocketOptions {
onAlchemyFormulasUpdate?: (data: { action: 'add' | 'remove'; formula?: CharacterFormula; formulaId?: string }) => void;
onAlchemyPreparedUpdate?: (data: { action: 'add' | 'update' | 'remove' | 'prepare' | 'quick_alchemy'; item?: CharacterPreparedItem; items?: CharacterPreparedItem[]; itemId?: string; batchUsed?: number }) => void;
onAlchemyStateUpdate?: (data: CharacterAlchemyState) => void;
onDyingUpdate?: (data: DyingUpdateData) => void;
onFullUpdate?: (character: Character) => void;
}
@@ -58,6 +72,7 @@ export function useCharacterSocket({
onAlchemyFormulasUpdate,
onAlchemyPreparedUpdate,
onAlchemyStateUpdate,
onDyingUpdate,
onFullUpdate,
}: UseCharacterSocketOptions) {
const [isConnected, setIsConnected] = useState(false);
@@ -76,6 +91,7 @@ export function useCharacterSocket({
onAlchemyFormulasUpdate,
onAlchemyPreparedUpdate,
onAlchemyStateUpdate,
onDyingUpdate,
onFullUpdate,
});
@@ -93,6 +109,7 @@ export function useCharacterSocket({
onAlchemyFormulasUpdate,
onAlchemyPreparedUpdate,
onAlchemyStateUpdate,
onDyingUpdate,
onFullUpdate,
};
});
@@ -202,6 +219,9 @@ export function useCharacterSocket({
case 'alchemy_state':
callbacks.onAlchemyStateUpdate?.(update.data);
break;
case 'dying':
callbacks.onDyingUpdate?.(update.data);
break;
}
});