From eb5b7fdf059ffb06fed31b09c48d8c6007e5ee8b Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Wed, 21 Jan 2026 14:39:21 +0100 Subject: [PATCH] feat: Add PF2e dying and death system - 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 --- .../components/character-sheet-page.tsx | 72 ++- .../characters/components/dying-indicator.tsx | 105 +++ .../characters/components/hp-control.tsx | 98 ++- .../components/recovery-check-modal.tsx | 239 +++++++ .../src/shared/hooks/use-character-socket.ts | 22 +- .../modules/characters/characters.gateway.ts | 66 +- .../modules/characters/characters.service.ts | 601 +++++++++++++++++- .../src/modules/characters/dto/dying.dto.ts | 57 ++ server/src/modules/characters/dto/index.ts | 1 + 9 files changed, 1253 insertions(+), 8 deletions(-) create mode 100644 client/src/features/characters/components/dying-indicator.tsx create mode 100644 client/src/features/characters/components/recovery-check-modal.tsx create mode 100644 server/src/modules/characters/dto/dying.dto.ts diff --git a/client/src/features/characters/components/character-sheet-page.tsx b/client/src/features/characters/components/character-sheet-page.tsx index 645f2ad..5bc1e95 100644 --- a/client/src/features/characters/components/character-sheet-page.tsx +++ b/client/src/features/characters/components/character-sheet-page.tsx @@ -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 (
{/* 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 && ( + 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')); + } + } + ); + }); + }} + /> + )}
); } diff --git a/client/src/features/characters/components/dying-indicator.tsx b/client/src/features/characters/components/dying-indicator.tsx new file mode 100644 index 0000000..5df5935 --- /dev/null +++ b/client/src/features/characters/components/dying-indicator.tsx @@ -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 ( +
+ {/* Dying Status */} + {isDying && ( +
+ {/* Header with skull and title */} +
+ + + Sterbend + +
+ + {/* Dying value indicators - larger */} +
+ {Array.from({ length: deathThreshold }).map((_, i) => ( +
+ {i < dyingValue && } +
+ ))} +
+ + {/* Info row */} +
+ + Genesungswurf SG {10 + dyingValue} + + + Tod bei {deathThreshold} + +
+ + {/* Recovery Check Button - Prominent */} + {onRecoveryCheck && ( + + )} +
+ )} + + {/* Wounded & Doomed Status Bar */} + {(woundedValue > 0 || doomedValue > 0) && ( +
+ {woundedValue > 0 && ( +
+ + Verwundet {woundedValue} +
+ )} + {doomedValue > 0 && ( +
+ + Verdammt {doomedValue} +
+ )} +
+ )} +
+ ); +} diff --git a/client/src/features/characters/components/hp-control.tsx b/client/src/features/characters/components/hp-control.tsx index 11bc301..b1ee704 100644 --- a/client/src/features/characters/components/hp-control.tsx +++ b/client/src/features/characters/components/hp-control.tsx @@ -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; + 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('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 ( + + +
+ {/* Death Icon */} +
+ +
+ + {/* Death Text */} +

Tot

+

+ Dieser Charakter ist gestorben. +

+ + {/* Revive Button */} + {onRevive && ( + + )} +
+
+
+ ); + } + + // When dying, show a completely different view focused on recovery + if (isDying) { + return ( + + + {/* Dying State - Full Width */} + + + {/* Heal Button - Primary action when dying */} +
+ +
+
+
+ ); + } + return ( @@ -113,6 +196,17 @@ export function HpControl({ hpCurrent, hpMax, hpTemp = 0, onHpChange }: HpContro + {/* Wounded/Doomed indicators (when not dying) */} + {(woundedValue > 0 || doomedValue > 0) && ( +
+ +
+ )} + {/* Quick Action Buttons */}
+
+ + {/* Content */} +
+ {/* Dying Status */} +
+
+ + Sterbend {dyingValue} +
+
+ {Array.from({ length: deathThreshold }).map((_, i) => ( +
+ ))} +
+
+ + {/* DC Display */} +
+

Schwierigkeitsgrad

+

{dc}

+
+ + {/* Result Display */} + {result && ( +
+
+ {result.died ? ( + + ) : result.stabilized ? ( + + ) : result.result === 'critical_success' || result.result === 'success' ? ( + + ) : ( + + )} + + {getResultName(result.result)} + +
+

{result.message}

+ {result.woundedAdded && !result.died && ( +

+ + Verwundet hinzugefugt +

+ )} +
+ )} + + {/* Outcome Buttons - Only show if no result yet */} + {!result && ( +
+

Ergebnis des Wurfs:

+ + {/* Success buttons */} +
+ + +
+ + {/* Failure buttons */} +
+ + +
+
+ )} + + {/* Close Button (after result) */} + {result && ( + + )} +
+
+ + ); +} diff --git a/client/src/shared/hooks/use-character-socket.ts b/client/src/shared/hooks/use-character-socket.ts index 814fd9f..71cee68 100644 --- a/client/src/shared/hooks/use-character-socket.ts +++ b/client/src/shared/hooks/use-character-socket.ts @@ -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; } }); diff --git a/server/src/modules/characters/characters.gateway.ts b/server/src/modules/characters/characters.gateway.ts index 551eba2..5839f06 100644 --- a/server/src/modules/characters/characters.gateway.ts +++ b/server/src/modules/characters/characters.gateway.ts @@ -8,10 +8,11 @@ import { MessageBody, } from '@nestjs/websockets'; 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 { ConfigService } from '@nestjs/config'; import { PrismaService } from '../../prisma/prisma.service'; +import { CharactersService } from './characters.service'; interface AuthenticatedSocket extends Socket { userId?: string; @@ -20,7 +21,7 @@ interface AuthenticatedSocket extends Socket { export interface CharacterUpdatePayload { 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; } @@ -52,6 +53,8 @@ export class CharactersGateway implements OnGatewayConnection, OnGatewayDisconne private jwtService: JwtService, private configService: ConfigService, private prisma: PrismaService, + @Inject(forwardRef(() => CharactersService)) + private charactersService: CharactersService, ) {} async handleConnection(client: AuthenticatedSocket) { @@ -166,6 +169,65 @@ export class CharactersGateway implements OnGatewayConnection, OnGatewayDisconne 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 broadcastCharacterUpdate(characterId: string, update: CharacterUpdatePayload) { const room = `character:${characterId}`; diff --git a/server/src/modules/characters/characters.service.ts b/server/src/modules/characters/characters.service.ts index 1662211..79022eb 100644 --- a/server/src/modules/characters/characters.service.ts +++ b/server/src/modules/characters/characters.service.ts @@ -23,6 +23,9 @@ import { RestPreviewDto, RestResultDto, ConditionReduced, + RecoveryCheckResultDto, + ApplyDamageResultDto, + HealFromDyingResultDto, } from './dto'; @Injectable() @@ -257,14 +260,33 @@ export class CharactersService { // HP Management async updateHp(id: string, hpCurrent: number, hpTemp?: number, userId?: string) { + console.log('[updateHp] Called with:', { id, hpCurrent, hpTemp, userId }); + if (userId) { 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({ where: { id }, data: { - hpCurrent: Math.max(0, hpCurrent), + hpCurrent: newHp, ...(hpTemp !== undefined && { hpTemp: Math.max(0, hpTemp) }), }, }); @@ -276,6 +298,111 @@ export class CharactersService { 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; } @@ -852,4 +979,476 @@ export class CharactersService { 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 { + 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 { + 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 { + 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' }; + } } diff --git a/server/src/modules/characters/dto/dying.dto.ts b/server/src/modules/characters/dto/dying.dto.ts new file mode 100644 index 0000000..81cc8fc --- /dev/null +++ b/server/src/modules/characters/dto/dying.dto.ts @@ -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; +} diff --git a/server/src/modules/characters/dto/index.ts b/server/src/modules/characters/dto/index.ts index 178d2ba..9f70cc1 100644 --- a/server/src/modules/characters/dto/index.ts +++ b/server/src/modules/characters/dto/index.ts @@ -3,3 +3,4 @@ export * from './update-character.dto'; export * from './pathbuilder-import.dto'; export * from './rest.dto'; export * from './alchemy.dto'; +export * from './dying.dto';