feat: Add PF2e dying and death system
All checks were successful
Deploy Dimension47 / deploy (push) Successful in 36s
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:
@@ -40,6 +40,7 @@ import { FeatDetailModal } from './feat-detail-modal';
|
|||||||
import { ActionsTab } from './actions-tab';
|
import { ActionsTab } from './actions-tab';
|
||||||
import { RestModal } from './rest-modal';
|
import { RestModal } from './rest-modal';
|
||||||
import { AlchemyTab } from './alchemy-tab';
|
import { AlchemyTab } from './alchemy-tab';
|
||||||
|
import { RecoveryCheckModal } from './recovery-check-modal';
|
||||||
import { useCharacterSocket } from '@/shared/hooks/use-character-socket';
|
import { useCharacterSocket } from '@/shared/hooks/use-character-socket';
|
||||||
import { downloadCharacterHTML } from '../utils/export-character-html';
|
import { downloadCharacterHTML } from '../utils/export-character-html';
|
||||||
import type { Character, CharacterItem, CharacterFeat, Campaign } from '@/shared/types';
|
import type { Character, CharacterItem, CharacterFeat, Campaign } from '@/shared/types';
|
||||||
@@ -128,6 +129,7 @@ export function CharacterSheetPage() {
|
|||||||
const [editingCredits, setEditingCredits] = useState(false);
|
const [editingCredits, setEditingCredits] = useState(false);
|
||||||
const [creditsInput, setCreditsInput] = useState('');
|
const [creditsInput, setCreditsInput] = useState('');
|
||||||
const [showRestModal, setShowRestModal] = useState(false);
|
const [showRestModal, setShowRestModal] = useState(false);
|
||||||
|
const [showRecoveryCheckModal, setShowRecoveryCheckModal] = useState(false);
|
||||||
|
|
||||||
const isOwner = character?.ownerId === user?.id;
|
const isOwner = character?.ownerId === user?.id;
|
||||||
const isGM = campaign?.gmId === user?.id;
|
const isGM = campaign?.gmId === user?.id;
|
||||||
@@ -154,7 +156,7 @@ export function CharacterSheetPage() {
|
|||||||
}, [campaignId, characterId]);
|
}, [campaignId, characterId]);
|
||||||
|
|
||||||
// WebSocket connection for real-time sync
|
// WebSocket connection for real-time sync
|
||||||
useCharacterSocket({
|
const { socket } = useCharacterSocket({
|
||||||
characterId: characterId || '',
|
characterId: characterId || '',
|
||||||
onHpUpdate: (data) => {
|
onHpUpdate: (data) => {
|
||||||
setCharacter((prev) => prev ? { ...prev, hpCurrent: data.hpCurrent, hpTemp: data.hpTemp, hpMax: data.hpMax } : null);
|
setCharacter((prev) => prev ? { ...prev, hpCurrent: data.hpCurrent, hpTemp: data.hpTemp, hpMax: data.hpMax } : null);
|
||||||
@@ -303,6 +305,16 @@ export function CharacterSheetPage() {
|
|||||||
onAlchemyStateUpdate: (data) => {
|
onAlchemyStateUpdate: (data) => {
|
||||||
setCharacter((prev) => prev ? { ...prev, alchemyState: data } : prev);
|
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) => {
|
onFullUpdate: (updatedCharacter) => {
|
||||||
setCharacter(updatedCharacter);
|
setCharacter(updatedCharacter);
|
||||||
},
|
},
|
||||||
@@ -311,8 +323,10 @@ export function CharacterSheetPage() {
|
|||||||
const handleHpChange = async (newHp: number) => {
|
const handleHpChange = async (newHp: number) => {
|
||||||
if (!character || !campaignId) return;
|
if (!character || !campaignId) return;
|
||||||
const clampedHp = Math.max(0, Math.min(character.hpMax, newHp));
|
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);
|
await api.updateCharacterHp(campaignId, character.id, clampedHp);
|
||||||
setCharacter({ ...character, hpCurrent: clampedHp });
|
// WebSocket will handle dying/conditions updates
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
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
|
// Tab Content Renderers
|
||||||
const renderStatusTab = () => {
|
const renderStatusTab = () => {
|
||||||
// AC Berechnung
|
// AC Berechnung
|
||||||
@@ -601,6 +633,8 @@ export function CharacterSheetPage() {
|
|||||||
} | undefined;
|
} | undefined;
|
||||||
const speed = pbSpeed?.build?.attributes?.speed || 25;
|
const speed = pbSpeed?.build?.attributes?.speed || 25;
|
||||||
|
|
||||||
|
const { dyingValue, woundedValue, doomedValue } = getDyingState();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* HP Row - Full width on mobile */}
|
{/* HP Row - Full width on mobile */}
|
||||||
@@ -608,7 +642,15 @@ export function CharacterSheetPage() {
|
|||||||
hpCurrent={character.hpCurrent}
|
hpCurrent={character.hpCurrent}
|
||||||
hpMax={character.hpMax}
|
hpMax={character.hpMax}
|
||||||
hpTemp={character.hpTemp}
|
hpTemp={character.hpTemp}
|
||||||
|
dyingValue={dyingValue}
|
||||||
|
woundedValue={woundedValue}
|
||||||
|
doomedValue={doomedValue}
|
||||||
onHpChange={handleHpChange}
|
onHpChange={handleHpChange}
|
||||||
|
onRecoveryCheck={() => setShowRecoveryCheckModal(true)}
|
||||||
|
onRevive={() => {
|
||||||
|
if (!socket) return;
|
||||||
|
socket.emit('revive_character', { characterId: character.id });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* AC & Speed Row */}
|
{/* 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
105
client/src/features/characters/components/dying-indicator.tsx
Normal file
105
client/src/features/characters/components/dying-indicator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,33 @@
|
|||||||
import { useState } from 'react';
|
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 { Button, Card, CardContent, Input } from '@/shared/components/ui';
|
||||||
|
import { DyingIndicator } from './dying-indicator';
|
||||||
|
|
||||||
interface HpControlProps {
|
interface HpControlProps {
|
||||||
hpCurrent: number;
|
hpCurrent: number;
|
||||||
hpMax: number;
|
hpMax: number;
|
||||||
hpTemp?: number;
|
hpTemp?: number;
|
||||||
|
dyingValue?: number;
|
||||||
|
woundedValue?: number;
|
||||||
|
doomedValue?: number;
|
||||||
onHpChange: (newHp: number) => Promise<void>;
|
onHpChange: (newHp: number) => Promise<void>;
|
||||||
|
onRecoveryCheck?: () => void;
|
||||||
|
onRevive?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mode = 'view' | 'damage' | 'heal' | 'direct';
|
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 [mode, setMode] = useState<Mode>('view');
|
||||||
const [pendingChange, setPendingChange] = useState(0);
|
const [pendingChange, setPendingChange] = useState(0);
|
||||||
const [directValue, setDirectValue] = useState(hpCurrent);
|
const [directValue, setDirectValue] = useState(hpCurrent);
|
||||||
@@ -85,6 +101,73 @@ export function HpControl({ hpCurrent, hpMax, hpTemp = 0, onHpChange }: HpContro
|
|||||||
|
|
||||||
// View Mode - Main Display
|
// View Mode - Main Display
|
||||||
if (mode === 'view') {
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-4">
|
<CardContent className="py-4">
|
||||||
@@ -113,6 +196,17 @@ export function HpControl({ hpCurrent, hpMax, hpTemp = 0, onHpChange }: HpContro
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Quick Action Buttons */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ let globalSocketRefCount = 0;
|
|||||||
let currentCharacterId: string | null = null;
|
let currentCharacterId: string | null = null;
|
||||||
let connectionAttempted = false;
|
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 {
|
export interface CharacterUpdate {
|
||||||
characterId: string;
|
characterId: string;
|
||||||
@@ -29,6 +29,19 @@ export interface RestUpdateData {
|
|||||||
alchemyReset: boolean;
|
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 {
|
interface UseCharacterSocketOptions {
|
||||||
characterId: string;
|
characterId: string;
|
||||||
onHpUpdate?: (data: { hpCurrent: number; hpTemp: number; hpMax: number }) => void;
|
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;
|
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;
|
onAlchemyPreparedUpdate?: (data: { action: 'add' | 'update' | 'remove' | 'prepare' | 'quick_alchemy'; item?: CharacterPreparedItem; items?: CharacterPreparedItem[]; itemId?: string; batchUsed?: number }) => void;
|
||||||
onAlchemyStateUpdate?: (data: CharacterAlchemyState) => void;
|
onAlchemyStateUpdate?: (data: CharacterAlchemyState) => void;
|
||||||
|
onDyingUpdate?: (data: DyingUpdateData) => void;
|
||||||
onFullUpdate?: (character: Character) => void;
|
onFullUpdate?: (character: Character) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +72,7 @@ export function useCharacterSocket({
|
|||||||
onAlchemyFormulasUpdate,
|
onAlchemyFormulasUpdate,
|
||||||
onAlchemyPreparedUpdate,
|
onAlchemyPreparedUpdate,
|
||||||
onAlchemyStateUpdate,
|
onAlchemyStateUpdate,
|
||||||
|
onDyingUpdate,
|
||||||
onFullUpdate,
|
onFullUpdate,
|
||||||
}: UseCharacterSocketOptions) {
|
}: UseCharacterSocketOptions) {
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
@@ -76,6 +91,7 @@ export function useCharacterSocket({
|
|||||||
onAlchemyFormulasUpdate,
|
onAlchemyFormulasUpdate,
|
||||||
onAlchemyPreparedUpdate,
|
onAlchemyPreparedUpdate,
|
||||||
onAlchemyStateUpdate,
|
onAlchemyStateUpdate,
|
||||||
|
onDyingUpdate,
|
||||||
onFullUpdate,
|
onFullUpdate,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,6 +109,7 @@ export function useCharacterSocket({
|
|||||||
onAlchemyFormulasUpdate,
|
onAlchemyFormulasUpdate,
|
||||||
onAlchemyPreparedUpdate,
|
onAlchemyPreparedUpdate,
|
||||||
onAlchemyStateUpdate,
|
onAlchemyStateUpdate,
|
||||||
|
onDyingUpdate,
|
||||||
onFullUpdate,
|
onFullUpdate,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -202,6 +219,9 @@ export function useCharacterSocket({
|
|||||||
case 'alchemy_state':
|
case 'alchemy_state':
|
||||||
callbacks.onAlchemyStateUpdate?.(update.data);
|
callbacks.onAlchemyStateUpdate?.(update.data);
|
||||||
break;
|
break;
|
||||||
|
case 'dying':
|
||||||
|
callbacks.onDyingUpdate?.(update.data);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import {
|
|||||||
MessageBody,
|
MessageBody,
|
||||||
} from '@nestjs/websockets';
|
} from '@nestjs/websockets';
|
||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { CharactersService } from './characters.service';
|
||||||
|
|
||||||
interface AuthenticatedSocket extends Socket {
|
interface AuthenticatedSocket extends Socket {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
@@ -20,7 +21,7 @@ interface AuthenticatedSocket extends Socket {
|
|||||||
|
|
||||||
export interface CharacterUpdatePayload {
|
export interface CharacterUpdatePayload {
|
||||||
characterId: string;
|
characterId: string;
|
||||||
type: 'hp' | 'conditions' | 'item' | 'inventory' | 'money' | 'level' | 'equipment_status' | 'rest' | 'alchemy_vials' | 'alchemy_formulas' | 'alchemy_prepared' | 'alchemy_state';
|
type: 'hp' | 'conditions' | 'item' | 'inventory' | 'money' | 'level' | 'equipment_status' | 'rest' | 'alchemy_vials' | 'alchemy_formulas' | 'alchemy_prepared' | 'alchemy_state' | 'dying';
|
||||||
data: any;
|
data: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +53,8 @@ export class CharactersGateway implements OnGatewayConnection, OnGatewayDisconne
|
|||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
|
@Inject(forwardRef(() => CharactersService))
|
||||||
|
private charactersService: CharactersService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handleConnection(client: AuthenticatedSocket) {
|
async handleConnection(client: AuthenticatedSocket) {
|
||||||
@@ -166,6 +169,65 @@ export class CharactersGateway implements OnGatewayConnection, OnGatewayDisconne
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('recovery_check')
|
||||||
|
async handleRecoveryCheck(
|
||||||
|
@ConnectedSocket() client: AuthenticatedSocket,
|
||||||
|
@MessageBody() data: { characterId: string; dieRoll: number },
|
||||||
|
) {
|
||||||
|
if (!client.userId) {
|
||||||
|
return { success: false, error: 'Not authenticated' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.charactersService.performRecoveryCheck(
|
||||||
|
data.characterId,
|
||||||
|
data.dieRoll,
|
||||||
|
client.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true, result };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error performing recovery check: ${error}`);
|
||||||
|
return { success: false, error: error instanceof Error ? error.message : 'Failed to perform recovery check' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('get_dying_state')
|
||||||
|
async handleGetDyingState(
|
||||||
|
@ConnectedSocket() client: AuthenticatedSocket,
|
||||||
|
@MessageBody() data: { characterId: string },
|
||||||
|
) {
|
||||||
|
if (!client.userId) {
|
||||||
|
return { success: false, error: 'Not authenticated' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = await this.charactersService.getDyingState(data.characterId, client.userId);
|
||||||
|
return { success: true, state };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error getting dying state: ${error}`);
|
||||||
|
return { success: false, error: error instanceof Error ? error.message : 'Failed to get dying state' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('revive_character')
|
||||||
|
async handleReviveCharacter(
|
||||||
|
@ConnectedSocket() client: AuthenticatedSocket,
|
||||||
|
@MessageBody() data: { characterId: string },
|
||||||
|
) {
|
||||||
|
if (!client.userId) {
|
||||||
|
return { success: false, error: 'Not authenticated' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.charactersService.reviveCharacter(data.characterId, client.userId);
|
||||||
|
return { success: true, result };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error reviving character: ${error}`);
|
||||||
|
return { success: false, error: error instanceof Error ? error.message : 'Failed to revive character' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Broadcast character update to all clients in the room
|
// Broadcast character update to all clients in the room
|
||||||
broadcastCharacterUpdate(characterId: string, update: CharacterUpdatePayload) {
|
broadcastCharacterUpdate(characterId: string, update: CharacterUpdatePayload) {
|
||||||
const room = `character:${characterId}`;
|
const room = `character:${characterId}`;
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ import {
|
|||||||
RestPreviewDto,
|
RestPreviewDto,
|
||||||
RestResultDto,
|
RestResultDto,
|
||||||
ConditionReduced,
|
ConditionReduced,
|
||||||
|
RecoveryCheckResultDto,
|
||||||
|
ApplyDamageResultDto,
|
||||||
|
HealFromDyingResultDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -257,14 +260,33 @@ export class CharactersService {
|
|||||||
|
|
||||||
// HP Management
|
// HP Management
|
||||||
async updateHp(id: string, hpCurrent: number, hpTemp?: number, userId?: string) {
|
async updateHp(id: string, hpCurrent: number, hpTemp?: number, userId?: string) {
|
||||||
|
console.log('[updateHp] Called with:', { id, hpCurrent, hpTemp, userId });
|
||||||
|
|
||||||
if (userId) {
|
if (userId) {
|
||||||
await this.checkCharacterAccess(id, userId, true);
|
await this.checkCharacterAccess(id, userId, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get current character state
|
||||||
|
const character = await this.prisma.character.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { conditions: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!character) {
|
||||||
|
throw new NotFoundException('Character not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldHp = character.hpCurrent;
|
||||||
|
const newHp = Math.max(0, hpCurrent);
|
||||||
|
const droppedToZero = oldHp > 0 && newHp === 0;
|
||||||
|
|
||||||
|
console.log('[updateHp] HP change:', { oldHp, newHp, droppedToZero });
|
||||||
|
|
||||||
|
// Update HP
|
||||||
const result = await this.prisma.character.update({
|
const result = await this.prisma.character.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
hpCurrent: Math.max(0, hpCurrent),
|
hpCurrent: newHp,
|
||||||
...(hpTemp !== undefined && { hpTemp: Math.max(0, hpTemp) }),
|
...(hpTemp !== undefined && { hpTemp: Math.max(0, hpTemp) }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -276,6 +298,111 @@ export class CharactersService {
|
|||||||
data: { hpCurrent: result.hpCurrent, hpTemp: result.hpTemp, hpMax: result.hpMax },
|
data: { hpCurrent: result.hpCurrent, hpTemp: result.hpTemp, hpMax: result.hpMax },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// If HP dropped to 0, add Dying condition
|
||||||
|
if (droppedToZero) {
|
||||||
|
console.log('[updateHp] Character dropped to 0 HP, adding Dying condition');
|
||||||
|
const { dying, wounded, doomed } = await this.getDyingConditions(id);
|
||||||
|
const deathThreshold = this.getDeathThreshold(doomed?.value || null);
|
||||||
|
|
||||||
|
// Calculate dying value: base 1 + wounded value
|
||||||
|
const woundedBonus = wounded?.value || 0;
|
||||||
|
let dyingValue = 1 + woundedBonus;
|
||||||
|
console.log('[updateHp] Dying value:', { dyingValue, woundedBonus, deathThreshold });
|
||||||
|
|
||||||
|
// Check for instant death
|
||||||
|
const died = dyingValue >= deathThreshold;
|
||||||
|
if (died) {
|
||||||
|
dyingValue = deathThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add or update Dying condition
|
||||||
|
if (dying) {
|
||||||
|
await this.prisma.characterCondition.update({
|
||||||
|
where: { id: dying.id },
|
||||||
|
data: { value: dyingValue },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.prisma.characterCondition.create({
|
||||||
|
data: {
|
||||||
|
characterId: id,
|
||||||
|
name: 'Dying',
|
||||||
|
nameGerman: 'Sterbend',
|
||||||
|
value: dyingValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast dying update
|
||||||
|
const updatedConditions = await this.prisma.characterCondition.findMany({
|
||||||
|
where: { characterId: id },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.charactersGateway.broadcastCharacterUpdate(id, {
|
||||||
|
characterId: id,
|
||||||
|
type: 'dying',
|
||||||
|
data: {
|
||||||
|
action: 'entered_dying',
|
||||||
|
dyingValue,
|
||||||
|
died,
|
||||||
|
conditions: updatedConditions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also broadcast conditions update for UI sync
|
||||||
|
this.charactersGateway.broadcastCharacterUpdate(id, {
|
||||||
|
characterId: id,
|
||||||
|
type: 'conditions',
|
||||||
|
data: {
|
||||||
|
action: dying ? 'update' : 'add',
|
||||||
|
condition: updatedConditions.find(c => c.name === 'Dying'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If HP increased from 0 and character was dying, handle recovery
|
||||||
|
if (oldHp === 0 && newHp > 0) {
|
||||||
|
const { dying, wounded } = await this.getDyingConditions(id);
|
||||||
|
|
||||||
|
if (dying) {
|
||||||
|
// Remove Dying condition
|
||||||
|
await this.prisma.characterCondition.delete({ where: { id: dying.id } });
|
||||||
|
|
||||||
|
// Add or increment Wounded
|
||||||
|
let newWoundedValue = 1;
|
||||||
|
if (wounded) {
|
||||||
|
newWoundedValue = (wounded.value || 0) + 1;
|
||||||
|
await this.prisma.characterCondition.update({
|
||||||
|
where: { id: wounded.id },
|
||||||
|
data: { value: newWoundedValue },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.prisma.characterCondition.create({
|
||||||
|
data: {
|
||||||
|
characterId: id,
|
||||||
|
name: 'Wounded',
|
||||||
|
nameGerman: 'Verwundet',
|
||||||
|
value: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast dying update
|
||||||
|
const updatedConditions = await this.prisma.characterCondition.findMany({
|
||||||
|
where: { characterId: id },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.charactersGateway.broadcastCharacterUpdate(id, {
|
||||||
|
characterId: id,
|
||||||
|
type: 'dying',
|
||||||
|
data: {
|
||||||
|
action: 'healed_from_dying',
|
||||||
|
woundedValue: newWoundedValue,
|
||||||
|
conditions: updatedConditions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -852,4 +979,476 @@ export class CharactersService {
|
|||||||
alchemyReset,
|
alchemyReset,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// DYING SYSTEM (PF2e Death & Dying)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current dying-related conditions for a character
|
||||||
|
*/
|
||||||
|
private async getDyingConditions(characterId: string) {
|
||||||
|
const conditions = await this.prisma.characterCondition.findMany({
|
||||||
|
where: { characterId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const dying = conditions.find(
|
||||||
|
(c) => c.name.toLowerCase() === 'dying' || c.nameGerman?.toLowerCase() === 'sterbend',
|
||||||
|
);
|
||||||
|
const wounded = conditions.find(
|
||||||
|
(c) => c.name.toLowerCase() === 'wounded' || c.nameGerman?.toLowerCase() === 'verwundet',
|
||||||
|
);
|
||||||
|
const doomed = conditions.find(
|
||||||
|
(c) => c.name.toLowerCase() === 'doomed' || c.nameGerman?.toLowerCase() === 'verdammt',
|
||||||
|
);
|
||||||
|
|
||||||
|
return { dying, wounded, doomed, all: conditions };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the death threshold (4 - doomed value)
|
||||||
|
*/
|
||||||
|
private getDeathThreshold(doomedValue: number | null): number {
|
||||||
|
return 4 - (doomedValue || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply damage to a character, handling dying mechanics when HP reaches 0
|
||||||
|
* PF2e Rules:
|
||||||
|
* - When HP reaches 0, gain Dying 1 (Dying 2 if critical hit)
|
||||||
|
* - Add Wounded value to Dying when going unconscious
|
||||||
|
* - Death occurs at Dying 4 (or lower with Doomed)
|
||||||
|
*/
|
||||||
|
async applyDamage(
|
||||||
|
characterId: string,
|
||||||
|
damage: number,
|
||||||
|
isCritical = false,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<ApplyDamageResultDto> {
|
||||||
|
if (userId) {
|
||||||
|
await this.checkCharacterAccess(characterId, userId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const character = await this.prisma.character.findUnique({
|
||||||
|
where: { id: characterId },
|
||||||
|
include: { conditions: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!character) {
|
||||||
|
throw new NotFoundException('Character not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dying, wounded, doomed } = await this.getDyingConditions(characterId);
|
||||||
|
const deathThreshold = this.getDeathThreshold(doomed?.value || null);
|
||||||
|
|
||||||
|
// Calculate new HP
|
||||||
|
let newHp = character.hpCurrent - damage;
|
||||||
|
const droppedToZero = character.hpCurrent > 0 && newHp <= 0;
|
||||||
|
newHp = Math.max(0, newHp);
|
||||||
|
|
||||||
|
// Update HP
|
||||||
|
await this.prisma.character.update({
|
||||||
|
where: { id: characterId },
|
||||||
|
data: { hpCurrent: newHp },
|
||||||
|
});
|
||||||
|
|
||||||
|
let dyingValue: number | null = dying?.value || null;
|
||||||
|
let died = false;
|
||||||
|
let message = `${damage} Schaden erhalten.`;
|
||||||
|
|
||||||
|
if (droppedToZero) {
|
||||||
|
// Character dropped to 0 HP - enter dying state
|
||||||
|
const baseDying = isCritical ? 2 : 1;
|
||||||
|
const woundedBonus = wounded?.value || 0;
|
||||||
|
dyingValue = baseDying + woundedBonus;
|
||||||
|
|
||||||
|
// Check for instant death
|
||||||
|
if (dyingValue >= deathThreshold) {
|
||||||
|
died = true;
|
||||||
|
dyingValue = deathThreshold;
|
||||||
|
message = `${damage} Schaden! ${character.name} ist tot.`;
|
||||||
|
} else {
|
||||||
|
message = `${damage} Schaden! ${character.name} wird bewusstlos mit Sterbend ${dyingValue}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add or update Dying condition
|
||||||
|
if (dying) {
|
||||||
|
await this.prisma.characterCondition.update({
|
||||||
|
where: { id: dying.id },
|
||||||
|
data: { value: dyingValue },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.prisma.characterCondition.create({
|
||||||
|
data: {
|
||||||
|
characterId,
|
||||||
|
name: 'Dying',
|
||||||
|
nameGerman: 'Sterbend',
|
||||||
|
value: dyingValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast conditions update
|
||||||
|
const updatedConditions = await this.prisma.characterCondition.findMany({
|
||||||
|
where: { characterId },
|
||||||
|
});
|
||||||
|
this.charactersGateway.broadcastCharacterUpdate(characterId, {
|
||||||
|
characterId,
|
||||||
|
type: 'dying',
|
||||||
|
data: {
|
||||||
|
action: 'entered_dying',
|
||||||
|
dyingValue,
|
||||||
|
died,
|
||||||
|
conditions: updatedConditions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (dying && newHp === 0) {
|
||||||
|
// Already dying, taking damage while at 0 HP increases Dying by 1 (2 if crit)
|
||||||
|
const increase = isCritical ? 2 : 1;
|
||||||
|
dyingValue = Math.min((dying.value || 0) + increase, deathThreshold);
|
||||||
|
|
||||||
|
if (dyingValue >= deathThreshold) {
|
||||||
|
died = true;
|
||||||
|
message = `${damage} Schaden! ${character.name} ist tot.`;
|
||||||
|
} else {
|
||||||
|
message = `${damage} Schaden! Sterbend erhöht auf ${dyingValue}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.characterCondition.update({
|
||||||
|
where: { id: dying.id },
|
||||||
|
data: { value: dyingValue },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.charactersGateway.broadcastCharacterUpdate(characterId, {
|
||||||
|
characterId,
|
||||||
|
type: 'dying',
|
||||||
|
data: {
|
||||||
|
action: 'dying_increased',
|
||||||
|
dyingValue,
|
||||||
|
died,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast HP update
|
||||||
|
this.charactersGateway.broadcastCharacterUpdate(characterId, {
|
||||||
|
characterId,
|
||||||
|
type: 'hp',
|
||||||
|
data: { hpCurrent: newHp, hpTemp: character.hpTemp, hpMax: character.hpMax },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
newHp,
|
||||||
|
dyingValue,
|
||||||
|
died,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a recovery check for a dying character
|
||||||
|
* PF2e Rules:
|
||||||
|
* - DC = 10 + current Dying value
|
||||||
|
* - Critical Success: Reduce Dying by 2
|
||||||
|
* - Success: Reduce Dying by 1
|
||||||
|
* - Failure: Increase Dying by 1
|
||||||
|
* - Critical Failure: Increase Dying by 2
|
||||||
|
* - At Dying 0, become stable (unconscious) and gain Wounded 1 (or +1)
|
||||||
|
* - At Dying 4+ (or lower with Doomed), character dies
|
||||||
|
*/
|
||||||
|
async performRecoveryCheck(
|
||||||
|
characterId: string,
|
||||||
|
dieRoll: number,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<RecoveryCheckResultDto> {
|
||||||
|
if (userId) {
|
||||||
|
await this.checkCharacterAccess(characterId, userId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dying, wounded, doomed } = await this.getDyingConditions(characterId);
|
||||||
|
|
||||||
|
if (!dying || !dying.value || dying.value <= 0) {
|
||||||
|
throw new ForbiddenException('Character is not dying');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dc = 10 + dying.value;
|
||||||
|
const deathThreshold = this.getDeathThreshold(doomed?.value || null);
|
||||||
|
|
||||||
|
// Determine result
|
||||||
|
let result: 'critical_success' | 'success' | 'failure' | 'critical_failure';
|
||||||
|
let dyingChange: number;
|
||||||
|
|
||||||
|
if (dieRoll === 20 || dieRoll >= dc + 10) {
|
||||||
|
result = 'critical_success';
|
||||||
|
dyingChange = -2;
|
||||||
|
} else if (dieRoll >= dc) {
|
||||||
|
result = 'success';
|
||||||
|
dyingChange = -1;
|
||||||
|
} else if (dieRoll === 1 || dieRoll <= dc - 10) {
|
||||||
|
result = 'critical_failure';
|
||||||
|
dyingChange = 2;
|
||||||
|
} else {
|
||||||
|
result = 'failure';
|
||||||
|
dyingChange = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newDyingValue = Math.max(0, (dying.value || 0) + dyingChange);
|
||||||
|
const stabilized = newDyingValue <= 0;
|
||||||
|
const died = newDyingValue >= deathThreshold;
|
||||||
|
let woundedAdded = false;
|
||||||
|
|
||||||
|
// Generate message
|
||||||
|
let message: string;
|
||||||
|
const resultNames = {
|
||||||
|
critical_success: 'Kritischer Erfolg',
|
||||||
|
success: 'Erfolg',
|
||||||
|
failure: 'Fehlschlag',
|
||||||
|
critical_failure: 'Kritischer Fehlschlag',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (died) {
|
||||||
|
message = `${resultNames[result]}! Sterbend erreicht ${newDyingValue}. Der Charakter ist tot.`;
|
||||||
|
} else if (stabilized) {
|
||||||
|
message = `${resultNames[result]}! Der Charakter ist stabil und bewusstlos.`;
|
||||||
|
} else {
|
||||||
|
message = `${resultNames[result]}! Sterbend ${dying.value} → ${newDyingValue}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Dying condition
|
||||||
|
if (stabilized) {
|
||||||
|
// Remove Dying
|
||||||
|
await this.prisma.characterCondition.delete({ where: { id: dying.id } });
|
||||||
|
|
||||||
|
// Add or increment Wounded
|
||||||
|
if (wounded) {
|
||||||
|
await this.prisma.characterCondition.update({
|
||||||
|
where: { id: wounded.id },
|
||||||
|
data: { value: (wounded.value || 0) + 1 },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.prisma.characterCondition.create({
|
||||||
|
data: {
|
||||||
|
characterId,
|
||||||
|
name: 'Wounded',
|
||||||
|
nameGerman: 'Verwundet',
|
||||||
|
value: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
woundedAdded = true;
|
||||||
|
} else {
|
||||||
|
// Update Dying value
|
||||||
|
await this.prisma.characterCondition.update({
|
||||||
|
where: { id: dying.id },
|
||||||
|
data: { value: Math.min(newDyingValue, deathThreshold) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast update
|
||||||
|
const updatedConditions = await this.prisma.characterCondition.findMany({
|
||||||
|
where: { characterId },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.charactersGateway.broadcastCharacterUpdate(characterId, {
|
||||||
|
characterId,
|
||||||
|
type: 'dying',
|
||||||
|
data: {
|
||||||
|
action: 'recovery_check',
|
||||||
|
result,
|
||||||
|
dieRoll,
|
||||||
|
dc,
|
||||||
|
newDyingValue: stabilized ? 0 : Math.min(newDyingValue, deathThreshold),
|
||||||
|
stabilized,
|
||||||
|
died,
|
||||||
|
woundedAdded,
|
||||||
|
conditions: updatedConditions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
result,
|
||||||
|
dieRoll,
|
||||||
|
dc,
|
||||||
|
newDyingValue: stabilized ? 0 : Math.min(newDyingValue, deathThreshold),
|
||||||
|
stabilized,
|
||||||
|
died,
|
||||||
|
message,
|
||||||
|
woundedAdded,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heal a character who is dying
|
||||||
|
* PF2e Rules:
|
||||||
|
* - When healed from 0 HP, remove Dying and gain Wounded 1 (or +1)
|
||||||
|
*/
|
||||||
|
async healFromDying(
|
||||||
|
characterId: string,
|
||||||
|
healAmount: number,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<HealFromDyingResultDto> {
|
||||||
|
if (userId) {
|
||||||
|
await this.checkCharacterAccess(characterId, userId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const character = await this.prisma.character.findUnique({
|
||||||
|
where: { id: characterId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!character) {
|
||||||
|
throw new NotFoundException('Character not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dying, wounded } = await this.getDyingConditions(characterId);
|
||||||
|
|
||||||
|
// Calculate new HP
|
||||||
|
const newHp = Math.min(character.hpMax, character.hpCurrent + healAmount);
|
||||||
|
|
||||||
|
// Update HP
|
||||||
|
await this.prisma.character.update({
|
||||||
|
where: { id: characterId },
|
||||||
|
data: { hpCurrent: newHp },
|
||||||
|
});
|
||||||
|
|
||||||
|
let woundedValue = wounded?.value || 0;
|
||||||
|
let message = `${healAmount} HP geheilt. HP: ${newHp}.`;
|
||||||
|
|
||||||
|
// If character was dying, remove Dying and add Wounded
|
||||||
|
if (dying) {
|
||||||
|
await this.prisma.characterCondition.delete({ where: { id: dying.id } });
|
||||||
|
|
||||||
|
if (wounded) {
|
||||||
|
woundedValue = (wounded.value || 0) + 1;
|
||||||
|
await this.prisma.characterCondition.update({
|
||||||
|
where: { id: wounded.id },
|
||||||
|
data: { value: woundedValue },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
woundedValue = 1;
|
||||||
|
await this.prisma.characterCondition.create({
|
||||||
|
data: {
|
||||||
|
characterId,
|
||||||
|
name: 'Wounded',
|
||||||
|
nameGerman: 'Verwundet',
|
||||||
|
value: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
message = `${healAmount} HP geheilt! Sterbend entfernt, Verwundet ${woundedValue}.`;
|
||||||
|
|
||||||
|
// Broadcast dying update
|
||||||
|
const updatedConditions = await this.prisma.characterCondition.findMany({
|
||||||
|
where: { characterId },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.charactersGateway.broadcastCharacterUpdate(characterId, {
|
||||||
|
characterId,
|
||||||
|
type: 'dying',
|
||||||
|
data: {
|
||||||
|
action: 'healed_from_dying',
|
||||||
|
woundedValue,
|
||||||
|
conditions: updatedConditions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast HP update
|
||||||
|
this.charactersGateway.broadcastCharacterUpdate(characterId, {
|
||||||
|
characterId,
|
||||||
|
type: 'hp',
|
||||||
|
data: { hpCurrent: newHp, hpTemp: character.hpTemp, hpMax: character.hpMax },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
newHp,
|
||||||
|
woundedValue,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current dying state for a character
|
||||||
|
*/
|
||||||
|
async getDyingState(characterId: string, userId?: string) {
|
||||||
|
if (userId) {
|
||||||
|
await this.checkCharacterAccess(characterId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dying, wounded, doomed } = await this.getDyingConditions(characterId);
|
||||||
|
const deathThreshold = this.getDeathThreshold(doomed?.value || null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDying: !!dying && (dying.value || 0) > 0,
|
||||||
|
dyingValue: dying?.value || 0,
|
||||||
|
woundedValue: wounded?.value || 0,
|
||||||
|
doomedValue: doomed?.value || 0,
|
||||||
|
deathThreshold,
|
||||||
|
recoveryDC: dying ? 10 + (dying.value || 0) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revive a dead character
|
||||||
|
* Removes Dying condition, sets HP to 1, and optionally clears Wounded/Doomed
|
||||||
|
*/
|
||||||
|
async reviveCharacter(characterId: string, userId?: string) {
|
||||||
|
if (userId) {
|
||||||
|
await this.checkCharacterAccess(characterId, userId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const character = await this.prisma.character.findUnique({
|
||||||
|
where: { id: characterId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!character) {
|
||||||
|
throw new NotFoundException('Character not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dying, wounded, doomed } = await this.getDyingConditions(characterId);
|
||||||
|
|
||||||
|
// Remove Dying condition
|
||||||
|
if (dying) {
|
||||||
|
await this.prisma.characterCondition.delete({ where: { id: dying.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove Wounded condition (fresh start after resurrection)
|
||||||
|
if (wounded) {
|
||||||
|
await this.prisma.characterCondition.delete({ where: { id: wounded.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove Doomed condition
|
||||||
|
if (doomed) {
|
||||||
|
await this.prisma.characterCondition.delete({ where: { id: doomed.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set HP to 1
|
||||||
|
await this.prisma.character.update({
|
||||||
|
where: { id: characterId },
|
||||||
|
data: { hpCurrent: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Broadcast HP update
|
||||||
|
this.charactersGateway.broadcastCharacterUpdate(characterId, {
|
||||||
|
characterId,
|
||||||
|
type: 'hp',
|
||||||
|
data: { hpCurrent: 1, hpTemp: character.hpTemp, hpMax: character.hpMax },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Broadcast conditions update
|
||||||
|
const updatedConditions = await this.prisma.characterCondition.findMany({
|
||||||
|
where: { characterId },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.charactersGateway.broadcastCharacterUpdate(characterId, {
|
||||||
|
characterId,
|
||||||
|
type: 'dying',
|
||||||
|
data: {
|
||||||
|
action: 'revived',
|
||||||
|
conditions: updatedConditions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, message: 'Charakter wiederbelebt' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
57
server/src/modules/characters/dto/dying.dto.ts
Normal file
57
server/src/modules/characters/dto/dying.dto.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { IsString, IsInt, Min, Max, IsBoolean, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
|
export class RecoveryCheckDto {
|
||||||
|
@IsString()
|
||||||
|
characterId: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(20)
|
||||||
|
dieRoll: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RecoveryCheckResultDto {
|
||||||
|
result: 'critical_success' | 'success' | 'failure' | 'critical_failure';
|
||||||
|
dieRoll: number;
|
||||||
|
dc: number;
|
||||||
|
newDyingValue: number;
|
||||||
|
stabilized: boolean;
|
||||||
|
died: boolean;
|
||||||
|
message: string;
|
||||||
|
woundedAdded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApplyDamageDto {
|
||||||
|
@IsString()
|
||||||
|
characterId: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
damage: number;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isCritical?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApplyDamageResultDto {
|
||||||
|
newHp: number;
|
||||||
|
dyingValue: number | null;
|
||||||
|
died: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HealFromDyingDto {
|
||||||
|
@IsString()
|
||||||
|
characterId: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
healAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HealFromDyingResultDto {
|
||||||
|
newHp: number;
|
||||||
|
woundedValue: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
@@ -3,3 +3,4 @@ export * from './update-character.dto';
|
|||||||
export * from './pathbuilder-import.dto';
|
export * from './pathbuilder-import.dto';
|
||||||
export * from './rest.dto';
|
export * from './rest.dto';
|
||||||
export * from './alchemy.dto';
|
export * from './alchemy.dto';
|
||||||
|
export * from './dying.dto';
|
||||||
|
|||||||
Reference in New Issue
Block a user