Files
Dimension-47/server/src/modules/battle/battle.service.ts
Alexander Zielonka 10370f0e90
Some checks failed
Deploy Dimension47 / deploy (push) Failing after 23s
feat: Add battle screen with real-time sync (Phase 1 MVP)
- Add battle module with sessions, maps, tokens, and combatants
- Implement WebSocket gateway for real-time battle updates
- Add map upload with configurable grid system
- Create battle canvas with token rendering and drag support
- Support PC tokens from characters and NPC tokens from templates
- Add initiative tracking and round management
- GM-only controls for token manipulation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:59:03 +01:00

378 lines
10 KiB
TypeScript

import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateBattleSessionDto, UpdateBattleSessionDto, CreateBattleTokenDto, UpdateBattleTokenDto } from './dto';
@Injectable()
export class BattleService {
constructor(private prisma: PrismaService) {}
// ==========================================
// BATTLE SESSIONS
// ==========================================
async findAllSessions(campaignId: string, userId: string) {
await this.verifyAccess(campaignId, userId);
return this.prisma.battleSession.findMany({
where: { campaignId },
include: {
map: true,
tokens: {
include: {
combatant: true,
character: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
}
async findSession(campaignId: string, sessionId: string, userId: string) {
await this.verifyAccess(campaignId, userId);
const session = await this.prisma.battleSession.findFirst({
where: { id: sessionId, campaignId },
include: {
map: true,
tokens: {
include: {
combatant: {
include: { abilities: true },
},
character: {
include: {
abilities: true,
conditions: true,
},
},
},
orderBy: [
{ initiative: 'desc' },
{ name: 'asc' },
],
},
},
});
if (!session) {
throw new NotFoundException('Battle session not found');
}
return session;
}
async createSession(campaignId: string, dto: CreateBattleSessionDto, userId: string) {
await this.verifyGMAccess(campaignId, userId);
// Verify map exists if provided
if (dto.mapId) {
const map = await this.prisma.battleMap.findFirst({
where: { id: dto.mapId, campaignId },
});
if (!map) {
throw new NotFoundException('Battle map not found');
}
}
return this.prisma.battleSession.create({
data: {
campaignId,
name: dto.name,
mapId: dto.mapId,
isActive: dto.isActive ?? false,
},
include: {
map: true,
tokens: true,
},
});
}
async updateSession(campaignId: string, sessionId: string, dto: UpdateBattleSessionDto, userId: string) {
await this.verifyGMAccess(campaignId, userId);
const session = await this.prisma.battleSession.findFirst({
where: { id: sessionId, campaignId },
});
if (!session) {
throw new NotFoundException('Battle session not found');
}
// Verify map exists if changing
if (dto.mapId) {
const map = await this.prisma.battleMap.findFirst({
where: { id: dto.mapId, campaignId },
});
if (!map) {
throw new NotFoundException('Battle map not found');
}
}
return this.prisma.battleSession.update({
where: { id: sessionId },
data: {
name: dto.name,
mapId: dto.mapId,
isActive: dto.isActive,
roundNumber: dto.roundNumber,
},
include: {
map: true,
tokens: {
include: {
combatant: true,
character: true,
},
},
},
});
}
async deleteSession(campaignId: string, sessionId: string, userId: string) {
await this.verifyGMAccess(campaignId, userId);
const session = await this.prisma.battleSession.findFirst({
where: { id: sessionId, campaignId },
});
if (!session) {
throw new NotFoundException('Battle session not found');
}
await this.prisma.battleSession.delete({
where: { id: sessionId },
});
return { message: 'Battle session deleted' };
}
// ==========================================
// BATTLE TOKENS
// ==========================================
async addToken(campaignId: string, sessionId: string, dto: CreateBattleTokenDto, userId: string) {
await this.verifyGMAccess(campaignId, userId);
const session = await this.prisma.battleSession.findFirst({
where: { id: sessionId, campaignId },
});
if (!session) {
throw new NotFoundException('Battle session not found');
}
// Verify combatant or character exists if provided
if (dto.combatantId) {
const combatant = await this.prisma.combatant.findFirst({
where: { id: dto.combatantId, campaignId },
});
if (!combatant) {
throw new NotFoundException('Combatant not found');
}
}
if (dto.characterId) {
const character = await this.prisma.character.findFirst({
where: { id: dto.characterId, campaignId },
});
if (!character) {
throw new NotFoundException('Character not found');
}
}
return this.prisma.battleToken.create({
data: {
battleSessionId: sessionId,
combatantId: dto.combatantId,
characterId: dto.characterId,
name: dto.name,
positionX: dto.positionX,
positionY: dto.positionY,
hpCurrent: dto.hpCurrent,
hpMax: dto.hpMax,
initiative: dto.initiative,
conditions: dto.conditions ?? [],
size: dto.size ?? 1,
},
include: {
combatant: {
include: { abilities: true },
},
character: true,
},
});
}
async updateToken(campaignId: string, sessionId: string, tokenId: string, dto: UpdateBattleTokenDto, userId: string) {
await this.verifyGMAccess(campaignId, userId);
const token = await this.prisma.battleToken.findFirst({
where: { id: tokenId, battleSessionId: sessionId },
include: { battleSession: true },
});
if (!token || token.battleSession.campaignId !== campaignId) {
throw new NotFoundException('Battle token not found');
}
return this.prisma.battleToken.update({
where: { id: tokenId },
data: {
name: dto.name,
positionX: dto.positionX,
positionY: dto.positionY,
hpCurrent: dto.hpCurrent,
hpMax: dto.hpMax,
initiative: dto.initiative,
conditions: dto.conditions,
size: dto.size,
},
include: {
combatant: {
include: { abilities: true },
},
character: true,
},
});
}
async removeToken(campaignId: string, sessionId: string, tokenId: string, userId: string) {
await this.verifyGMAccess(campaignId, userId);
const token = await this.prisma.battleToken.findFirst({
where: { id: tokenId, battleSessionId: sessionId },
include: { battleSession: true },
});
if (!token || token.battleSession.campaignId !== campaignId) {
throw new NotFoundException('Battle token not found');
}
await this.prisma.battleToken.delete({
where: { id: tokenId },
});
return { message: 'Token removed' };
}
async moveToken(campaignId: string, sessionId: string, tokenId: string, positionX: number, positionY: number, userId: string) {
await this.verifyGMAccess(campaignId, userId);
const token = await this.prisma.battleToken.findFirst({
where: { id: tokenId, battleSessionId: sessionId },
include: { battleSession: true },
});
if (!token || token.battleSession.campaignId !== campaignId) {
throw new NotFoundException('Battle token not found');
}
return this.prisma.battleToken.update({
where: { id: tokenId },
data: { positionX, positionY },
include: {
combatant: true,
character: true,
},
});
}
// ==========================================
// INITIATIVE
// ==========================================
async setInitiative(campaignId: string, sessionId: string, tokenId: string, initiative: number, userId: string) {
await this.verifyGMAccess(campaignId, userId);
const token = await this.prisma.battleToken.findFirst({
where: { id: tokenId, battleSessionId: sessionId },
include: { battleSession: true },
});
if (!token || token.battleSession.campaignId !== campaignId) {
throw new NotFoundException('Battle token not found');
}
return this.prisma.battleToken.update({
where: { id: tokenId },
data: { initiative },
include: {
combatant: true,
character: true,
},
});
}
async advanceRound(campaignId: string, sessionId: string, userId: string) {
await this.verifyGMAccess(campaignId, userId);
const session = await this.prisma.battleSession.findFirst({
where: { id: sessionId, campaignId },
});
if (!session) {
throw new NotFoundException('Battle session not found');
}
return this.prisma.battleSession.update({
where: { id: sessionId },
data: { roundNumber: session.roundNumber + 1 },
include: {
map: true,
tokens: {
include: {
combatant: true,
character: true,
},
orderBy: [
{ initiative: 'desc' },
{ name: 'asc' },
],
},
},
});
}
// ==========================================
// ACCESS HELPERS
// ==========================================
private async verifyAccess(campaignId: string, userId: string) {
const campaign = await this.prisma.campaign.findUnique({
where: { id: campaignId },
include: { members: true },
});
if (!campaign) {
throw new NotFoundException('Campaign not found');
}
const isGM = campaign.gmId === userId;
const isMember = campaign.members.some(m => m.userId === userId);
if (!isGM && !isMember) {
throw new ForbiddenException('No access to this campaign');
}
return { campaign, isGM };
}
private async verifyGMAccess(campaignId: string, userId: string) {
const campaign = await this.prisma.campaign.findUnique({
where: { id: campaignId },
});
if (!campaign) {
throw new NotFoundException('Campaign not found');
}
if (campaign.gmId !== userId) {
throw new ForbiddenException('Only the GM can perform this action');
}
return campaign;
}
}