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:
@@ -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}`;
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
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 './rest.dto';
|
||||
export * from './alchemy.dto';
|
||||
export * from './dying.dto';
|
||||
|
||||
Reference in New Issue
Block a user