feat: Add battle screen with real-time sync (Phase 1 MVP)
All checks were successful
Deploy Dimension47 / deploy (push) Successful in 35s

- 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>
This commit is contained in:
Alexander Zielonka
2026-01-30 09:59:03 +01:00
parent f4c7386358
commit d6f2b62bd7
27 changed files with 3390 additions and 37 deletions

View File

@@ -17,6 +17,7 @@
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-socket.io": "^11.1.12",
"@nestjs/serve-static": "^5.0.4",
"@nestjs/swagger": "^11.2.5",
"@nestjs/websockets": "^11.1.12",
"@prisma/adapter-pg": "^7.2.0",
@@ -259,7 +260,6 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -841,8 +841,7 @@
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.2.tgz",
"integrity": "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true
"license": "Apache-2.0"
},
"node_modules/@electric-sql/pglite-socket": {
"version": "0.0.6",
@@ -2724,7 +2723,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.12.tgz",
"integrity": "sha512-v6U3O01YohHO+IE3EIFXuRuu3VJILWzyMmSYZXpyBbnp0hk0mFyHxK2w3dF4I5WnbwiRbWlEXdeXFvPQ7qaZzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"file-type": "21.3.0",
"iterare": "1.2.1",
@@ -2784,7 +2782,6 @@
"integrity": "sha512-97DzTYMf5RtGAVvX1cjwpKRiCUpkeQ9CCzSAenqkAhOmNVVFaApbhuw+xrDt13rsCa2hHVOYPrV4dBgOYMJjsA==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@nuxt/opencollective": "0.4.1",
"fast-safe-stringify": "2.1.1",
@@ -2868,7 +2865,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.12.tgz",
"integrity": "sha512-GYK/vHI0SGz5m8mxr7v3Urx8b9t78Cf/dj5aJMZlGd9/1D9OI1hAl00BaphjEXINUJ/BQLxIlF2zUjrYsd6enQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"cors": "2.8.5",
"express": "5.2.1",
@@ -2890,7 +2886,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.12.tgz",
"integrity": "sha512-1itTTYsAZecrq2NbJOkch32y8buLwN7UpcNRdJrhlS+ovJ5GxLx3RyJ3KylwBhbYnO5AeYyL1U/i4W5mg/4qDA==",
"license": "MIT",
"peer": true,
"dependencies": {
"socket.io": "4.8.3",
"tslib": "2.8.1"
@@ -3003,6 +2998,33 @@
"tslib": "^2.1.0"
}
},
"node_modules/@nestjs/serve-static": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@nestjs/serve-static/-/serve-static-5.0.4.tgz",
"integrity": "sha512-3kO1M9D3vsPyWPFardxIjUYeuolS58PnhCoBTkS7t3BrdZFZCKHnBZ15js+UOzOR2Q6HmD7ssGjLd0DVYVdvOw==",
"license": "MIT",
"dependencies": {
"path-to-regexp": "8.3.0"
},
"peerDependencies": {
"@fastify/static": "^8.0.4",
"@nestjs/common": "^11.0.2",
"@nestjs/core": "^11.0.2",
"express": "^5.0.1",
"fastify": "^5.2.1"
},
"peerDependenciesMeta": {
"@fastify/static": {
"optional": true
},
"express": {
"optional": true
},
"fastify": {
"optional": true
}
}
},
"node_modules/@nestjs/swagger": {
"version": "11.2.5",
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.2.5.tgz",
@@ -3069,7 +3091,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.12.tgz",
"integrity": "sha512-ulSOYcgosx1TqY425cRC5oXtAu1R10+OSmVfgyR9ueR25k4luekURt8dzAZxhxSCI0OsDj9WKCFLTkEuAwg0wg==",
"license": "MIT",
"peer": true,
"dependencies": {
"iterare": "1.2.1",
"object-hash": "3.0.0",
@@ -3542,7 +3563,6 @@
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "*",
"@types/json-schema": "*"
@@ -3681,7 +3701,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -3863,7 +3882,6 @@
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.53.0",
"@typescript-eslint/types": "8.53.0",
@@ -4545,7 +4563,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4595,7 +4612,6 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -5038,7 +5054,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5300,7 +5315,6 @@
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
@@ -5358,15 +5372,13 @@
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/class-validator": {
"version": "0.14.3",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz",
"integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/validator": "^13.15.3",
"libphonenumber-js": "^1.11.1",
@@ -5720,7 +5732,8 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/debug": {
"version": "4.4.3",
@@ -6226,7 +6239,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -6287,7 +6299,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -6520,7 +6531,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -7271,7 +7281,6 @@
"integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==",
"devOptional": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -7658,7 +7667,6 @@
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "30.2.0",
"@jest/types": "30.2.0",
@@ -9457,7 +9465,6 @@
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"passport-strategy": "1.x.x",
"pause": "0.0.1",
@@ -9590,7 +9597,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.17.1.tgz",
"integrity": "sha512-EIR+jXdYNSMOrpRp7g6WgQr7SaZNZfS7IzZIO0oTNEeibq956JxeD15t3Jk3zZH0KH8DmOIx38qJfQenoE8bXQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.10.0",
"pg-pool": "^3.11.0",
@@ -9874,7 +9880,6 @@
"integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -9933,7 +9938,6 @@
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@prisma/config": "7.2.0",
"@prisma/dev": "0.17.0",
@@ -10144,8 +10148,7 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0",
"peer": true
"license": "Apache-2.0"
},
"node_modules/regexp-to-ast": {
"version": "0.5.0",
@@ -10292,7 +10295,6 @@
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
@@ -10328,7 +10330,8 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"devOptional": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/schema-utils": {
"version": "3.3.0",
@@ -11037,7 +11040,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -11381,7 +11383,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -11549,7 +11550,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -11831,7 +11831,6 @@
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@@ -11901,7 +11900,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",

View File

@@ -38,6 +38,7 @@
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-socket.io": "^11.1.12",
"@nestjs/serve-static": "^5.0.4",
"@nestjs/swagger": "^11.2.5",
"@nestjs/websockets": "^11.1.12",
"@prisma/adapter-pg": "^7.2.0",

View File

@@ -1,6 +1,8 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
// Core Modules
import { PrismaModule } from './prisma/prisma.module';
@@ -13,6 +15,7 @@ import { CharactersModule } from './modules/characters/characters.module';
import { TranslationsModule } from './modules/translations/translations.module';
import { EquipmentModule } from './modules/equipment/equipment.module';
import { FeatsModule } from './modules/feats/feats.module';
import { BattleModule } from './modules/battle/battle.module';
// Guards
import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
@@ -26,6 +29,12 @@ import { RolesGuard } from './modules/auth/guards/roles.guard';
envFilePath: '.env',
}),
// Static file serving for uploads
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'uploads'),
serveRoot: '/uploads',
}),
// Core
PrismaModule,
ClaudeModule,
@@ -37,6 +46,7 @@ import { RolesGuard } from './modules/auth/guards/roles.guard';
TranslationsModule,
EquipmentModule,
FeatsModule,
BattleModule,
],
providers: [
// Global JWT Auth Guard

View File

@@ -0,0 +1,113 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseInterceptors,
UploadedFile,
ParseFilePipe,
MaxFileSizeValidator,
FileTypeValidator,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiConsumes,
ApiBody,
} from '@nestjs/swagger';
import { BattleMapsService } from './battle-maps.service';
import { CreateBattleMapDto, UpdateBattleMapDto } from './dto';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('Battle')
@ApiBearerAuth()
@Controller('campaigns/:campaignId/battle-maps')
export class BattleMapsController {
constructor(private readonly battleMapsService: BattleMapsService) {}
@Get()
@ApiOperation({ summary: 'Get all battle maps for a campaign' })
@ApiResponse({ status: 200, description: 'List of battle maps' })
async findAll(
@Param('campaignId') campaignId: string,
@CurrentUser('id') userId: string,
) {
return this.battleMapsService.findAll(campaignId, userId);
}
@Get(':mapId')
@ApiOperation({ summary: 'Get a specific battle map' })
@ApiResponse({ status: 200, description: 'Battle map details' })
async findOne(
@Param('campaignId') campaignId: string,
@Param('mapId') mapId: string,
@CurrentUser('id') userId: string,
) {
return this.battleMapsService.findOne(campaignId, mapId, userId);
}
@Post()
@UseInterceptors(FileInterceptor('image'))
@ApiOperation({ summary: 'Upload a new battle map' })
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
name: { type: 'string' },
gridSizeX: { type: 'number' },
gridSizeY: { type: 'number' },
gridOffsetX: { type: 'number' },
gridOffsetY: { type: 'number' },
image: { type: 'string', format: 'binary' },
},
required: ['name', 'image'],
},
})
@ApiResponse({ status: 201, description: 'Battle map created' })
async create(
@Param('campaignId') campaignId: string,
@Body() dto: CreateBattleMapDto,
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 10 * 1024 * 1024 }), // 10MB
new FileTypeValidator({ fileType: /^image\/(png|jpeg|jpg|webp)$/ }),
],
}),
)
image: Express.Multer.File,
@CurrentUser('id') userId: string,
) {
return this.battleMapsService.create(campaignId, dto, image, userId);
}
@Put(':mapId')
@ApiOperation({ summary: 'Update battle map settings' })
@ApiResponse({ status: 200, description: 'Battle map updated' })
async update(
@Param('campaignId') campaignId: string,
@Param('mapId') mapId: string,
@Body() dto: UpdateBattleMapDto,
@CurrentUser('id') userId: string,
) {
return this.battleMapsService.update(campaignId, mapId, dto, userId);
}
@Delete(':mapId')
@ApiOperation({ summary: 'Delete a battle map' })
@ApiResponse({ status: 200, description: 'Battle map deleted' })
async delete(
@Param('campaignId') campaignId: string,
@Param('mapId') mapId: string,
@CurrentUser('id') userId: string,
) {
return this.battleMapsService.delete(campaignId, mapId, userId);
}
}

View File

@@ -0,0 +1,170 @@
import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateBattleMapDto, UpdateBattleMapDto } from './dto';
import * as fs from 'fs';
import * as path from 'path';
@Injectable()
export class BattleMapsService {
private readonly uploadDir = 'uploads/battle-maps';
constructor(private prisma: PrismaService) {
// Ensure upload directory exists
this.ensureUploadDir();
}
private ensureUploadDir() {
const fullPath = path.resolve(this.uploadDir);
if (!fs.existsSync(fullPath)) {
fs.mkdirSync(fullPath, { recursive: true });
}
}
async findAll(campaignId: string, userId: string) {
await this.verifyAccess(campaignId, userId);
return this.prisma.battleMap.findMany({
where: { campaignId },
orderBy: { createdAt: 'desc' },
});
}
async findOne(campaignId: string, mapId: string, userId: string) {
await this.verifyAccess(campaignId, userId);
const map = await this.prisma.battleMap.findFirst({
where: { id: mapId, campaignId },
});
if (!map) {
throw new NotFoundException('Battle map not found');
}
return map;
}
async create(
campaignId: string,
dto: CreateBattleMapDto,
imageFile: Express.Multer.File,
userId: string,
) {
await this.verifyGMAccess(campaignId, userId);
if (!imageFile) {
throw new BadRequestException('Map image is required');
}
// Generate unique filename
const ext = path.extname(imageFile.originalname);
const filename = `${Date.now()}-${Math.random().toString(36).substring(7)}${ext}`;
const filePath = path.join(this.uploadDir, filename);
// Save file
fs.writeFileSync(filePath, imageFile.buffer);
// Create relative URL for serving
const imageUrl = `/uploads/battle-maps/${filename}`;
return this.prisma.battleMap.create({
data: {
campaignId,
name: dto.name,
imageUrl,
gridSizeX: dto.gridSizeX ?? 20,
gridSizeY: dto.gridSizeY ?? 20,
gridOffsetX: dto.gridOffsetX ?? 0,
gridOffsetY: dto.gridOffsetY ?? 0,
},
});
}
async update(campaignId: string, mapId: string, dto: UpdateBattleMapDto, userId: string) {
await this.verifyGMAccess(campaignId, userId);
const map = await this.prisma.battleMap.findFirst({
where: { id: mapId, campaignId },
});
if (!map) {
throw new NotFoundException('Battle map not found');
}
return this.prisma.battleMap.update({
where: { id: mapId },
data: {
name: dto.name,
gridSizeX: dto.gridSizeX,
gridSizeY: dto.gridSizeY,
gridOffsetX: dto.gridOffsetX,
gridOffsetY: dto.gridOffsetY,
},
});
}
async delete(campaignId: string, mapId: string, userId: string) {
await this.verifyGMAccess(campaignId, userId);
const map = await this.prisma.battleMap.findFirst({
where: { id: mapId, campaignId },
});
if (!map) {
throw new NotFoundException('Battle map not found');
}
// Delete file from disk
if (map.imageUrl) {
const filePath = path.join(process.cwd(), map.imageUrl);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
await this.prisma.battleMap.delete({
where: { id: mapId },
});
return { message: 'Battle map deleted' };
}
// ==========================================
// 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;
}
}

View File

@@ -0,0 +1,167 @@
import {
Controller,
Get,
Post,
Put,
Patch,
Delete,
Body,
Param,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { BattleService } from './battle.service';
import { CreateBattleSessionDto, UpdateBattleSessionDto, CreateBattleTokenDto, UpdateBattleTokenDto } from './dto';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('Battle')
@ApiBearerAuth()
@Controller('campaigns/:campaignId/battles')
export class BattleController {
constructor(private readonly battleService: BattleService) {}
// ==========================================
// BATTLE SESSIONS
// ==========================================
@Get()
@ApiOperation({ summary: 'Get all battle sessions for a campaign' })
@ApiResponse({ status: 200, description: 'List of battle sessions' })
async findAllSessions(
@Param('campaignId') campaignId: string,
@CurrentUser('id') userId: string,
) {
return this.battleService.findAllSessions(campaignId, userId);
}
@Get(':sessionId')
@ApiOperation({ summary: 'Get a specific battle session' })
@ApiResponse({ status: 200, description: 'Battle session details' })
async findSession(
@Param('campaignId') campaignId: string,
@Param('sessionId') sessionId: string,
@CurrentUser('id') userId: string,
) {
return this.battleService.findSession(campaignId, sessionId, userId);
}
@Post()
@ApiOperation({ summary: 'Create a new battle session' })
@ApiResponse({ status: 201, description: 'Battle session created' })
async createSession(
@Param('campaignId') campaignId: string,
@Body() dto: CreateBattleSessionDto,
@CurrentUser('id') userId: string,
) {
return this.battleService.createSession(campaignId, dto, userId);
}
@Put(':sessionId')
@ApiOperation({ summary: 'Update a battle session' })
@ApiResponse({ status: 200, description: 'Battle session updated' })
async updateSession(
@Param('campaignId') campaignId: string,
@Param('sessionId') sessionId: string,
@Body() dto: UpdateBattleSessionDto,
@CurrentUser('id') userId: string,
) {
return this.battleService.updateSession(campaignId, sessionId, dto, userId);
}
@Delete(':sessionId')
@ApiOperation({ summary: 'Delete a battle session' })
@ApiResponse({ status: 200, description: 'Battle session deleted' })
async deleteSession(
@Param('campaignId') campaignId: string,
@Param('sessionId') sessionId: string,
@CurrentUser('id') userId: string,
) {
return this.battleService.deleteSession(campaignId, sessionId, userId);
}
// ==========================================
// BATTLE TOKENS
// ==========================================
@Post(':sessionId/tokens')
@ApiOperation({ summary: 'Add a token to the battle' })
@ApiResponse({ status: 201, description: 'Token added' })
async addToken(
@Param('campaignId') campaignId: string,
@Param('sessionId') sessionId: string,
@Body() dto: CreateBattleTokenDto,
@CurrentUser('id') userId: string,
) {
return this.battleService.addToken(campaignId, sessionId, dto, userId);
}
@Patch(':sessionId/tokens/:tokenId')
@ApiOperation({ summary: 'Update a token' })
@ApiResponse({ status: 200, description: 'Token updated' })
async updateToken(
@Param('campaignId') campaignId: string,
@Param('sessionId') sessionId: string,
@Param('tokenId') tokenId: string,
@Body() dto: UpdateBattleTokenDto,
@CurrentUser('id') userId: string,
) {
return this.battleService.updateToken(campaignId, sessionId, tokenId, dto, userId);
}
@Delete(':sessionId/tokens/:tokenId')
@ApiOperation({ summary: 'Remove a token from the battle' })
@ApiResponse({ status: 200, description: 'Token removed' })
async removeToken(
@Param('campaignId') campaignId: string,
@Param('sessionId') sessionId: string,
@Param('tokenId') tokenId: string,
@CurrentUser('id') userId: string,
) {
return this.battleService.removeToken(campaignId, sessionId, tokenId, userId);
}
@Patch(':sessionId/tokens/:tokenId/move')
@ApiOperation({ summary: 'Move a token on the map' })
@ApiResponse({ status: 200, description: 'Token moved' })
async moveToken(
@Param('campaignId') campaignId: string,
@Param('sessionId') sessionId: string,
@Param('tokenId') tokenId: string,
@Body() body: { positionX: number; positionY: number },
@CurrentUser('id') userId: string,
) {
return this.battleService.moveToken(campaignId, sessionId, tokenId, body.positionX, body.positionY, userId);
}
// ==========================================
// INITIATIVE
// ==========================================
@Patch(':sessionId/tokens/:tokenId/initiative')
@ApiOperation({ summary: 'Set token initiative' })
@ApiResponse({ status: 200, description: 'Initiative set' })
async setInitiative(
@Param('campaignId') campaignId: string,
@Param('sessionId') sessionId: string,
@Param('tokenId') tokenId: string,
@Body() body: { initiative: number },
@CurrentUser('id') userId: string,
) {
return this.battleService.setInitiative(campaignId, sessionId, tokenId, body.initiative, userId);
}
@Post(':sessionId/advance-round')
@ApiOperation({ summary: 'Advance to the next round' })
@ApiResponse({ status: 200, description: 'Round advanced' })
async advanceRound(
@Param('campaignId') campaignId: string,
@Param('sessionId') sessionId: string,
@CurrentUser('id') userId: string,
) {
return this.battleService.advanceRound(campaignId, sessionId, userId);
}
}

View File

@@ -0,0 +1,375 @@
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
ConnectedSocket,
MessageBody,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
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 { BattleService } from './battle.service';
interface AuthenticatedSocket extends Socket {
userId?: string;
username?: string;
}
export type BattleUpdateType =
| 'token_added'
| 'token_removed'
| 'token_moved'
| 'token_updated'
| 'token_hp_changed'
| 'initiative_set'
| 'round_advanced'
| 'session_updated'
| 'session_deleted';
export interface BattleUpdatePayload {
sessionId: string;
type: BattleUpdateType;
data: any;
}
// CORS origins from environment (fallback to common dev ports)
const getCorsOrigins = () => {
const origins = process.env.CORS_ORIGINS;
if (origins) {
return origins.split(',').map(o => o.trim());
}
return ['http://localhost:3000', 'http://localhost:5173', 'http://localhost:5175'];
};
@Injectable()
@WebSocketGateway({
cors: {
origin: getCorsOrigins(),
credentials: true,
},
namespace: '/battles',
})
export class BattleGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
private logger = new Logger('BattleGateway');
private connectedClients = new Map<string, Set<string>>(); // sessionId -> Set<socketId>
constructor(
private jwtService: JwtService,
private configService: ConfigService,
private prisma: PrismaService,
@Inject(forwardRef(() => BattleService))
private battleService: BattleService,
) {}
async handleConnection(client: AuthenticatedSocket) {
try {
const token = client.handshake.auth.token || client.handshake.headers.authorization?.split(' ')[1];
if (!token) {
this.logger.warn(`Client ${client.id} disconnected: No token provided`);
client.disconnect();
return;
}
const secret = this.configService.get<string>('JWT_SECRET');
const payload = this.jwtService.verify(token, { secret });
// Verify user exists
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
select: { id: true, username: true },
});
if (!user) {
this.logger.warn(`Client ${client.id} disconnected: User not found`);
client.disconnect();
return;
}
client.userId = user.id;
client.username = user.username;
this.logger.log(`Battle client connected: ${client.id} (User: ${user.username})`);
} catch (error) {
this.logger.warn(`Client ${client.id} disconnected: Invalid token`);
client.disconnect();
}
}
handleDisconnect(client: AuthenticatedSocket) {
// Remove client from all battle rooms
this.connectedClients.forEach((clients, sessionId) => {
clients.delete(client.id);
if (clients.size === 0) {
this.connectedClients.delete(sessionId);
}
});
this.logger.log(`Battle client disconnected: ${client.id}`);
}
@SubscribeMessage('join_battle')
async handleJoinBattle(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { sessionId: string },
) {
if (!client.userId) {
return { success: false, error: 'Not authenticated' };
}
try {
// Verify user has access to this battle session
const session = await this.prisma.battleSession.findUnique({
where: { id: data.sessionId },
include: {
campaign: {
include: { members: true }
}
},
});
if (!session) {
return { success: false, error: 'Battle session not found' };
}
const isGM = session.campaign.gmId === client.userId;
const isMember = session.campaign.members.some(m => m.userId === client.userId);
if (!isGM && !isMember) {
return { success: false, error: 'No access to this battle' };
}
// Join the room
const room = `battle:${data.sessionId}`;
client.join(room);
// Track connected clients
if (!this.connectedClients.has(data.sessionId)) {
this.connectedClients.set(data.sessionId, new Set());
}
this.connectedClients.get(data.sessionId)?.add(client.id);
this.logger.log(`Client ${client.id} joined battle room: ${data.sessionId} (isGM: ${isGM})`);
return { success: true, isGM };
} catch (error) {
this.logger.error(`Error joining battle room: ${error}`);
return { success: false, error: 'Failed to join battle room' };
}
}
@SubscribeMessage('leave_battle')
handleLeaveBattle(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { sessionId: string },
) {
const room = `battle:${data.sessionId}`;
client.leave(room);
this.connectedClients.get(data.sessionId)?.delete(client.id);
if (this.connectedClients.get(data.sessionId)?.size === 0) {
this.connectedClients.delete(data.sessionId);
}
this.logger.log(`Client ${client.id} left battle room: ${data.sessionId}`);
return { success: true };
}
@SubscribeMessage('move_token')
async handleMoveToken(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { sessionId: string; tokenId: string; positionX: number; positionY: number },
) {
if (!client.userId) {
return { success: false, error: 'Not authenticated' };
}
try {
// Get session to verify campaign
const session = await this.prisma.battleSession.findUnique({
where: { id: data.sessionId },
select: { campaignId: true },
});
if (!session) {
return { success: false, error: 'Session not found' };
}
// Move the token via service (this validates GM access)
const token = await this.battleService.moveToken(
session.campaignId,
data.sessionId,
data.tokenId,
data.positionX,
data.positionY,
client.userId,
);
// Broadcast to all clients in the room
this.broadcastBattleUpdate(data.sessionId, {
sessionId: data.sessionId,
type: 'token_moved',
data: {
tokenId: data.tokenId,
positionX: data.positionX,
positionY: data.positionY,
},
});
return { success: true, token };
} catch (error) {
this.logger.error(`Error moving token: ${error}`);
return { success: false, error: error instanceof Error ? error.message : 'Failed to move token' };
}
}
@SubscribeMessage('update_token_hp')
async handleUpdateTokenHp(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { sessionId: string; tokenId: string; hpCurrent: number },
) {
if (!client.userId) {
return { success: false, error: 'Not authenticated' };
}
try {
const session = await this.prisma.battleSession.findUnique({
where: { id: data.sessionId },
select: { campaignId: true },
});
if (!session) {
return { success: false, error: 'Session not found' };
}
const token = await this.battleService.updateToken(
session.campaignId,
data.sessionId,
data.tokenId,
{ hpCurrent: data.hpCurrent },
client.userId,
);
// Broadcast to all clients
this.broadcastBattleUpdate(data.sessionId, {
sessionId: data.sessionId,
type: 'token_hp_changed',
data: {
tokenId: data.tokenId,
hpCurrent: data.hpCurrent,
hpMax: token.hpMax,
},
});
return { success: true, token };
} catch (error) {
this.logger.error(`Error updating token HP: ${error}`);
return { success: false, error: error instanceof Error ? error.message : 'Failed to update HP' };
}
}
@SubscribeMessage('set_initiative')
async handleSetInitiative(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { sessionId: string; tokenId: string; initiative: number },
) {
if (!client.userId) {
return { success: false, error: 'Not authenticated' };
}
try {
const session = await this.prisma.battleSession.findUnique({
where: { id: data.sessionId },
select: { campaignId: true },
});
if (!session) {
return { success: false, error: 'Session not found' };
}
const token = await this.battleService.setInitiative(
session.campaignId,
data.sessionId,
data.tokenId,
data.initiative,
client.userId,
);
// Broadcast to all clients
this.broadcastBattleUpdate(data.sessionId, {
sessionId: data.sessionId,
type: 'initiative_set',
data: {
tokenId: data.tokenId,
initiative: data.initiative,
},
});
return { success: true, token };
} catch (error) {
this.logger.error(`Error setting initiative: ${error}`);
return { success: false, error: error instanceof Error ? error.message : 'Failed to set initiative' };
}
}
@SubscribeMessage('advance_round')
async handleAdvanceRound(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { sessionId: string },
) {
if (!client.userId) {
return { success: false, error: 'Not authenticated' };
}
try {
const session = await this.prisma.battleSession.findUnique({
where: { id: data.sessionId },
select: { campaignId: true },
});
if (!session) {
return { success: false, error: 'Session not found' };
}
const updatedSession = await this.battleService.advanceRound(
session.campaignId,
data.sessionId,
client.userId,
);
// Broadcast to all clients
this.broadcastBattleUpdate(data.sessionId, {
sessionId: data.sessionId,
type: 'round_advanced',
data: {
roundNumber: updatedSession.roundNumber,
},
});
return { success: true, session: updatedSession };
} catch (error) {
this.logger.error(`Error advancing round: ${error}`);
return { success: false, error: error instanceof Error ? error.message : 'Failed to advance round' };
}
}
// Broadcast battle update to all clients in the room
broadcastBattleUpdate(sessionId: string, update: BattleUpdatePayload) {
const room = `battle:${sessionId}`;
this.server.to(room).emit('battle_update', update);
this.logger.debug(`Broadcast to ${room}: ${update.type}`);
}
// Get number of connected clients for a battle session
getConnectedClientsCount(sessionId: string): number {
return this.connectedClients.get(sessionId)?.size || 0;
}
}

View File

@@ -0,0 +1,51 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MulterModule } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
// Controllers
import { BattleController } from './battle.controller';
import { BattleMapsController } from './battle-maps.controller';
import { CombatantsController } from './combatants.controller';
// Services
import { BattleService } from './battle.service';
import { BattleMapsService } from './battle-maps.service';
import { CombatantsService } from './combatants.service';
// Gateway
import { BattleGateway } from './battle.gateway';
@Module({
imports: [
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
}),
inject: [ConfigService],
}),
MulterModule.register({
storage: memoryStorage(),
}),
],
controllers: [
BattleController,
BattleMapsController,
CombatantsController,
],
providers: [
BattleService,
BattleMapsService,
CombatantsService,
BattleGateway,
],
exports: [
BattleService,
BattleMapsService,
CombatantsService,
BattleGateway,
],
})
export class BattleModule {}

View File

@@ -0,0 +1,377 @@
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;
}
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { CombatantsService } from './combatants.service';
import { CreateCombatantDto } from './dto';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('Battle')
@ApiBearerAuth()
@Controller('campaigns/:campaignId/combatants')
export class CombatantsController {
constructor(private readonly combatantsService: CombatantsService) {}
@Get()
@ApiOperation({ summary: 'Get all NPC/Monster templates for a campaign' })
@ApiResponse({ status: 200, description: 'List of combatants' })
async findAll(
@Param('campaignId') campaignId: string,
@CurrentUser('id') userId: string,
) {
return this.combatantsService.findAll(campaignId, userId);
}
@Get(':combatantId')
@ApiOperation({ summary: 'Get a specific combatant template' })
@ApiResponse({ status: 200, description: 'Combatant details' })
async findOne(
@Param('campaignId') campaignId: string,
@Param('combatantId') combatantId: string,
@CurrentUser('id') userId: string,
) {
return this.combatantsService.findOne(campaignId, combatantId, userId);
}
@Post()
@ApiOperation({ summary: 'Create a new NPC/Monster template' })
@ApiResponse({ status: 201, description: 'Combatant created' })
async create(
@Param('campaignId') campaignId: string,
@Body() dto: CreateCombatantDto,
@CurrentUser('id') userId: string,
) {
return this.combatantsService.create(campaignId, dto, userId);
}
@Put(':combatantId')
@ApiOperation({ summary: 'Update a combatant template' })
@ApiResponse({ status: 200, description: 'Combatant updated' })
async update(
@Param('campaignId') campaignId: string,
@Param('combatantId') combatantId: string,
@Body() dto: CreateCombatantDto,
@CurrentUser('id') userId: string,
) {
return this.combatantsService.update(campaignId, combatantId, dto, userId);
}
@Delete(':combatantId')
@ApiOperation({ summary: 'Delete a combatant template' })
@ApiResponse({ status: 200, description: 'Combatant deleted' })
async delete(
@Param('campaignId') campaignId: string,
@Param('combatantId') combatantId: string,
@CurrentUser('id') userId: string,
) {
return this.combatantsService.delete(campaignId, combatantId, userId);
}
}

View File

@@ -0,0 +1,178 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateCombatantDto } from './dto';
@Injectable()
export class CombatantsService {
constructor(private prisma: PrismaService) {}
async findAll(campaignId: string, userId: string) {
await this.verifyAccess(campaignId, userId);
return this.prisma.combatant.findMany({
where: { campaignId },
include: { abilities: true },
orderBy: [
{ level: 'asc' },
{ name: 'asc' },
],
});
}
async findOne(campaignId: string, combatantId: string, userId: string) {
await this.verifyAccess(campaignId, userId);
const combatant = await this.prisma.combatant.findFirst({
where: { id: combatantId, campaignId },
include: { abilities: true },
});
if (!combatant) {
throw new NotFoundException('Combatant not found');
}
return combatant;
}
async create(campaignId: string, dto: CreateCombatantDto, userId: string) {
await this.verifyGMAccess(campaignId, userId);
return this.prisma.combatant.create({
data: {
campaignId,
name: dto.name,
type: dto.type,
level: dto.level,
hpMax: dto.hpMax,
ac: dto.ac,
fortitude: dto.fortitude,
reflex: dto.reflex,
will: dto.will,
perception: dto.perception,
speed: dto.speed ?? 25,
avatarUrl: dto.avatarUrl,
description: dto.description,
abilities: {
create: dto.abilities?.map(ability => ({
name: ability.name,
actionCost: ability.actionCost,
actionType: ability.actionType,
description: ability.description,
damage: ability.damage,
traits: ability.traits ?? [],
})) ?? [],
},
},
include: { abilities: true },
});
}
async update(campaignId: string, combatantId: string, dto: Partial<CreateCombatantDto>, userId: string) {
await this.verifyGMAccess(campaignId, userId);
const combatant = await this.prisma.combatant.findFirst({
where: { id: combatantId, campaignId },
});
if (!combatant) {
throw new NotFoundException('Combatant not found');
}
// Update combatant and replace abilities if provided
return this.prisma.$transaction(async (tx) => {
// Delete existing abilities if new ones are provided
if (dto.abilities !== undefined) {
await tx.combatantAbility.deleteMany({
where: { combatantId },
});
}
return tx.combatant.update({
where: { id: combatantId },
data: {
name: dto.name,
type: dto.type,
level: dto.level,
hpMax: dto.hpMax,
ac: dto.ac,
fortitude: dto.fortitude,
reflex: dto.reflex,
will: dto.will,
perception: dto.perception,
speed: dto.speed,
avatarUrl: dto.avatarUrl,
description: dto.description,
abilities: dto.abilities ? {
create: dto.abilities.map(ability => ({
name: ability.name,
actionCost: ability.actionCost,
actionType: ability.actionType,
description: ability.description,
damage: ability.damage,
traits: ability.traits ?? [],
})),
} : undefined,
},
include: { abilities: true },
});
});
}
async delete(campaignId: string, combatantId: string, userId: string) {
await this.verifyGMAccess(campaignId, userId);
const combatant = await this.prisma.combatant.findFirst({
where: { id: combatantId, campaignId },
});
if (!combatant) {
throw new NotFoundException('Combatant not found');
}
await this.prisma.combatant.delete({
where: { id: combatantId },
});
return { message: 'Combatant deleted' };
}
// ==========================================
// 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;
}
}

View File

@@ -0,0 +1,30 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsInt, Min, IsOptional } from 'class-validator';
export class CreateBattleMapDto {
@ApiProperty({ description: 'Map name' })
@IsString()
name: string;
@ApiPropertyOptional({ description: 'Grid columns', default: 20 })
@IsOptional()
@IsInt()
@Min(1)
gridSizeX?: number = 20;
@ApiPropertyOptional({ description: 'Grid rows', default: 20 })
@IsOptional()
@IsInt()
@Min(1)
gridSizeY?: number = 20;
@ApiPropertyOptional({ description: 'Grid X offset', default: 0 })
@IsOptional()
@IsInt()
gridOffsetX?: number = 0;
@ApiPropertyOptional({ description: 'Grid Y offset', default: 0 })
@IsOptional()
@IsInt()
gridOffsetY?: number = 0;
}

View File

@@ -0,0 +1,19 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsBoolean, IsInt, Min } from 'class-validator';
export class CreateBattleSessionDto {
@ApiPropertyOptional({ description: 'Session name' })
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({ description: 'Battle map ID' })
@IsOptional()
@IsString()
mapId?: string;
@ApiPropertyOptional({ description: 'Whether the session is active', default: false })
@IsOptional()
@IsBoolean()
isActive?: boolean = false;
}

View File

@@ -0,0 +1,54 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsInt, IsNumber, IsOptional, IsArray, Min, Max } from 'class-validator';
export class CreateBattleTokenDto {
@ApiPropertyOptional({ description: 'Combatant template ID (for NPCs/Monsters)' })
@IsOptional()
@IsString()
combatantId?: string;
@ApiPropertyOptional({ description: 'Character ID (for PCs)' })
@IsOptional()
@IsString()
characterId?: string;
@ApiProperty({ description: 'Token display name' })
@IsString()
name: string;
@ApiProperty({ description: 'X position on grid' })
@IsNumber()
positionX: number;
@ApiProperty({ description: 'Y position on grid' })
@IsNumber()
positionY: number;
@ApiProperty({ description: 'Current HP' })
@IsInt()
@Min(0)
hpCurrent: number;
@ApiProperty({ description: 'Maximum HP' })
@IsInt()
@Min(1)
hpMax: number;
@ApiPropertyOptional({ description: 'Initiative value' })
@IsOptional()
@IsInt()
initiative?: number;
@ApiPropertyOptional({ description: 'Active conditions', type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
conditions?: string[] = [];
@ApiPropertyOptional({ description: 'Token size (1=Medium, 2=Large, etc.)', default: 1 })
@IsOptional()
@IsInt()
@Min(1)
@Max(6)
size?: number = 1;
}

View File

@@ -0,0 +1,100 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsInt, IsOptional, IsEnum, Min, Max, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { CombatantType, ActionType } from '../../../generated/prisma/client.js';
export class CreateCombatantAbilityDto {
@ApiProperty({ description: 'Ability name' })
@IsString()
name: string;
@ApiProperty({ description: 'Action cost (0 for free/reaction, 1-3 for actions)' })
@IsInt()
@Min(0)
@Max(3)
actionCost: number;
@ApiProperty({ enum: ['ACTION', 'REACTION', 'FREE'] })
@IsEnum(ActionType)
actionType: ActionType;
@ApiProperty({ description: 'Ability description' })
@IsString()
description: string;
@ApiPropertyOptional({ description: 'Damage dice (e.g. "2d6+4 slashing")' })
@IsOptional()
@IsString()
damage?: string;
@ApiPropertyOptional({ description: 'Ability traits', type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
traits?: string[] = [];
}
export class CreateCombatantDto {
@ApiProperty({ description: 'Combatant name' })
@IsString()
name: string;
@ApiProperty({ enum: ['PC', 'NPC', 'MONSTER'] })
@IsEnum(CombatantType)
type: CombatantType;
@ApiProperty({ description: 'Combatant level' })
@IsInt()
@Min(-1)
@Max(30)
level: number;
@ApiProperty({ description: 'Maximum HP' })
@IsInt()
@Min(1)
hpMax: number;
@ApiProperty({ description: 'Armor Class' })
@IsInt()
@Min(0)
ac: number;
@ApiProperty({ description: 'Fortitude save modifier' })
@IsInt()
fortitude: number;
@ApiProperty({ description: 'Reflex save modifier' })
@IsInt()
reflex: number;
@ApiProperty({ description: 'Will save modifier' })
@IsInt()
will: number;
@ApiProperty({ description: 'Perception modifier' })
@IsInt()
perception: number;
@ApiPropertyOptional({ description: 'Movement speed in feet', default: 25 })
@IsOptional()
@IsInt()
@Min(0)
speed?: number = 25;
@ApiPropertyOptional({ description: 'Avatar image URL' })
@IsOptional()
@IsString()
avatarUrl?: string;
@ApiPropertyOptional({ description: 'Description/notes' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: 'Combat abilities', type: [CreateCombatantAbilityDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateCombatantAbilityDto)
abilities?: CreateCombatantAbilityDto[] = [];
}

View File

@@ -0,0 +1,7 @@
export * from './create-battle-session.dto';
export * from './update-battle-session.dto';
export * from './create-battle-map.dto';
export * from './update-battle-map.dto';
export * from './create-battle-token.dto';
export * from './update-battle-token.dto';
export * from './create-combatant.dto';

View File

@@ -0,0 +1,31 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsInt, Min, IsOptional } from 'class-validator';
export class UpdateBattleMapDto {
@ApiPropertyOptional({ description: 'Map name' })
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({ description: 'Grid columns' })
@IsOptional()
@IsInt()
@Min(1)
gridSizeX?: number;
@ApiPropertyOptional({ description: 'Grid rows' })
@IsOptional()
@IsInt()
@Min(1)
gridSizeY?: number;
@ApiPropertyOptional({ description: 'Grid X offset' })
@IsOptional()
@IsInt()
gridOffsetX?: number;
@ApiPropertyOptional({ description: 'Grid Y offset' })
@IsOptional()
@IsInt()
gridOffsetY?: number;
}

View File

@@ -0,0 +1,25 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsBoolean, IsInt, Min } from 'class-validator';
export class UpdateBattleSessionDto {
@ApiPropertyOptional({ description: 'Session name' })
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({ description: 'Battle map ID' })
@IsOptional()
@IsString()
mapId?: string;
@ApiPropertyOptional({ description: 'Whether the session is active' })
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({ description: 'Current round number' })
@IsOptional()
@IsInt()
@Min(0)
roundNumber?: number;
}

View File

@@ -0,0 +1,49 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsInt, IsNumber, IsOptional, IsArray, Min, Max } from 'class-validator';
export class UpdateBattleTokenDto {
@ApiPropertyOptional({ description: 'Token display name' })
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({ description: 'X position on grid' })
@IsOptional()
@IsNumber()
positionX?: number;
@ApiPropertyOptional({ description: 'Y position on grid' })
@IsOptional()
@IsNumber()
positionY?: number;
@ApiPropertyOptional({ description: 'Current HP' })
@IsOptional()
@IsInt()
@Min(0)
hpCurrent?: number;
@ApiPropertyOptional({ description: 'Maximum HP' })
@IsOptional()
@IsInt()
@Min(1)
hpMax?: number;
@ApiPropertyOptional({ description: 'Initiative value' })
@IsOptional()
@IsInt()
initiative?: number;
@ApiPropertyOptional({ description: 'Active conditions', type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
conditions?: string[];
@ApiPropertyOptional({ description: 'Token size (1=Medium, 2=Large, etc.)' })
@IsOptional()
@IsInt()
@Min(1)
@Max(6)
size?: number;
}