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

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

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

View File

@@ -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}`;

View File

@@ -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<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' };
}
}

View 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;
}

View File

@@ -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';