Some checks failed
Deploy Dimension47 / deploy (push) Failing after 23s
- 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>
378 lines
10 KiB
TypeScript
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;
|
|
}
|
|
}
|