From d6f2b62bd786a2684cbfce28758196ba1f0dc0f5 Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Fri, 30 Jan 2026 09:59:03 +0100 Subject: [PATCH] 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 --- client/src/App.tsx | 2 + .../battle/components/battle-canvas.tsx | 238 +++++++++ .../battle/components/battle-page.tsx | 450 ++++++++++++++++++ .../battle/components/battle-session-list.tsx | 179 +++++++ .../src/features/battle/components/token.tsx | 124 +++++ .../battle/hooks/use-battle-socket.ts | 308 ++++++++++++ client/src/features/battle/index.ts | 5 + client/src/shared/lib/api.ts | 212 +++++++++ server/package-lock.json | 72 ++- server/package.json | 1 + server/src/app.module.ts | 10 + .../modules/battle/battle-maps.controller.ts | 113 +++++ .../src/modules/battle/battle-maps.service.ts | 170 +++++++ .../src/modules/battle/battle.controller.ts | 167 +++++++ server/src/modules/battle/battle.gateway.ts | 375 +++++++++++++++ server/src/modules/battle/battle.module.ts | 51 ++ server/src/modules/battle/battle.service.ts | 377 +++++++++++++++ .../modules/battle/combatants.controller.ts | 80 ++++ .../src/modules/battle/combatants.service.ts | 178 +++++++ .../battle/dto/create-battle-map.dto.ts | 30 ++ .../battle/dto/create-battle-session.dto.ts | 19 + .../battle/dto/create-battle-token.dto.ts | 54 +++ .../battle/dto/create-combatant.dto.ts | 100 ++++ server/src/modules/battle/dto/index.ts | 7 + .../battle/dto/update-battle-map.dto.ts | 31 ++ .../battle/dto/update-battle-session.dto.ts | 25 + .../battle/dto/update-battle-token.dto.ts | 49 ++ 27 files changed, 3390 insertions(+), 37 deletions(-) create mode 100644 client/src/features/battle/components/battle-canvas.tsx create mode 100644 client/src/features/battle/components/battle-page.tsx create mode 100644 client/src/features/battle/components/battle-session-list.tsx create mode 100644 client/src/features/battle/components/token.tsx create mode 100644 client/src/features/battle/hooks/use-battle-socket.ts create mode 100644 client/src/features/battle/index.ts create mode 100644 server/src/modules/battle/battle-maps.controller.ts create mode 100644 server/src/modules/battle/battle-maps.service.ts create mode 100644 server/src/modules/battle/battle.controller.ts create mode 100644 server/src/modules/battle/battle.gateway.ts create mode 100644 server/src/modules/battle/battle.module.ts create mode 100644 server/src/modules/battle/battle.service.ts create mode 100644 server/src/modules/battle/combatants.controller.ts create mode 100644 server/src/modules/battle/combatants.service.ts create mode 100644 server/src/modules/battle/dto/create-battle-map.dto.ts create mode 100644 server/src/modules/battle/dto/create-battle-session.dto.ts create mode 100644 server/src/modules/battle/dto/create-battle-token.dto.ts create mode 100644 server/src/modules/battle/dto/create-combatant.dto.ts create mode 100644 server/src/modules/battle/dto/index.ts create mode 100644 server/src/modules/battle/dto/update-battle-map.dto.ts create mode 100644 server/src/modules/battle/dto/update-battle-session.dto.ts create mode 100644 server/src/modules/battle/dto/update-battle-token.dto.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 755a16f..49e954d 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { LoginPage, RegisterPage, useAuthStore } from '@/features/auth'; import { CampaignsPage, CampaignDetailPage } from '@/features/campaigns'; import { CharacterSheetPage } from '@/features/characters'; +import { BattlePage } from '@/features/battle'; import { ProtectedRoute } from '@/shared/components/protected-route'; import { Layout } from '@/shared/components/layout'; @@ -46,6 +47,7 @@ function AppContent() { } /> } /> } /> + } /> Library (TODO)} /> diff --git a/client/src/features/battle/components/battle-canvas.tsx b/client/src/features/battle/components/battle-canvas.tsx new file mode 100644 index 0000000..1a5b80a --- /dev/null +++ b/client/src/features/battle/components/battle-canvas.tsx @@ -0,0 +1,238 @@ +import React, { useRef, useState, useCallback, useMemo, useEffect } from 'react'; +import type { BattleSession, BattleMap } from '@/shared/types'; +import { Token } from './token'; +import { cn } from '@/shared/lib/utils'; + +interface BattleCanvasProps { + session: BattleSession; + map: BattleMap | null; + isGM: boolean; + selectedTokenId: string | null; + onSelectToken: (tokenId: string | null) => void; + onMoveToken: (tokenId: string, positionX: number, positionY: number) => void; +} + +const API_URL = import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:5000'; + +export function BattleCanvas({ + session, + map, + isGM, + selectedTokenId, + onSelectToken, + onMoveToken, +}: BattleCanvasProps) { + const containerRef = useRef(null); + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); + const [draggedToken, setDraggedToken] = useState(null); + const [showGrid, setShowGrid] = useState(true); + + // Calculate cell size based on container and grid dimensions + const cellSize = useMemo(() => { + if (!map || containerSize.width === 0 || containerSize.height === 0) return 40; + + const cellWidth = containerSize.width / map.gridSizeX; + const cellHeight = containerSize.height / map.gridSizeY; + + // Use the smaller dimension to ensure grid fits + return Math.floor(Math.min(cellWidth, cellHeight)); + }, [map, containerSize]); + + // Calculate canvas dimensions + const canvasSize = useMemo(() => { + if (!map) return { width: 800, height: 600 }; + return { + width: cellSize * map.gridSizeX, + height: cellSize * map.gridSizeY, + }; + }, [map, cellSize]); + + // Update container size on resize + useEffect(() => { + const updateSize = () => { + if (containerRef.current) { + setContainerSize({ + width: containerRef.current.clientWidth, + height: containerRef.current.clientHeight, + }); + } + }; + + updateSize(); + window.addEventListener('resize', updateSize); + return () => window.removeEventListener('resize', updateSize); + }, []); + + // Handle drag start + const handleDragStart = useCallback((tokenId: string) => (e: React.DragEvent | React.TouchEvent) => { + if (!isGM) return; + + setDraggedToken(tokenId); + onSelectToken(tokenId); + + if ('dataTransfer' in e) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', tokenId); + } + }, [isGM, onSelectToken]); + + // Handle drop on canvas + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + if (!draggedToken || !isGM || !map) return; + + const rect = e.currentTarget.getBoundingClientRect(); + const x = e.clientX - rect.left - map.gridOffsetX; + const y = e.clientY - rect.top - map.gridOffsetY; + + // Calculate grid position + const gridX = Math.floor(x / cellSize); + const gridY = Math.floor(y / cellSize); + + // Clamp to grid bounds + const clampedX = Math.max(0, Math.min(gridX, map.gridSizeX - 1)); + const clampedY = Math.max(0, Math.min(gridY, map.gridSizeY - 1)); + + onMoveToken(draggedToken, clampedX, clampedY); + setDraggedToken(null); + }, [draggedToken, isGM, map, cellSize, onMoveToken]); + + // Handle drag over + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }, []); + + // Handle canvas click (deselect token) + const handleCanvasClick = useCallback((e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onSelectToken(null); + } + }, [onSelectToken]); + + // Render grid lines + const gridLines = useMemo(() => { + if (!map || !showGrid) return null; + + const lines: React.ReactElement[] = []; + + // Vertical lines + for (let x = 0; x <= map.gridSizeX; x++) { + lines.push( + + ); + } + + // Horizontal lines + for (let y = 0; y <= map.gridSizeY; y++) { + lines.push( + + ); + } + + return lines; + }, [map, showGrid, cellSize, canvasSize]); + + return ( +
+ {/* Toolbar */} +
+ + {map && ( + + {map.gridSizeX} x {map.gridSizeY} ({cellSize}px) + + )} +
+ + {/* Canvas container */} +
+
+ {/* Map background */} + {map && ( + {map.name} + )} + + {/* Grid overlay */} + + {gridLines} + + + {/* Tokens */} +
+ {session.tokens.map((token) => ( + onSelectToken(token.id)} + onDragStart={handleDragStart(token.id)} + /> + ))} +
+ + {/* No map placeholder */} + {!map && ( +
+

Keine Karte ausgewählt

+
+ )} +
+
+
+ ); +} diff --git a/client/src/features/battle/components/battle-page.tsx b/client/src/features/battle/components/battle-page.tsx new file mode 100644 index 0000000..dfb2d5d --- /dev/null +++ b/client/src/features/battle/components/battle-page.tsx @@ -0,0 +1,450 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { ArrowLeft, Plus, Users, Swords, ChevronRight } from 'lucide-react'; +import { api } from '@/shared/lib/api'; +import { useAuthStore } from '@/features/auth'; +import { BattleCanvas } from './battle-canvas'; +import { BattleSessionList } from './battle-session-list'; +import { useBattleSocket } from '../hooks/use-battle-socket'; +import type { BattleSession, BattleToken, Character, Combatant } from '@/shared/types'; +import { cn } from '@/shared/lib/utils'; + +export function BattlePage() { + const { id: campaignId } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { user } = useAuthStore(); + + const [selectedSessionId, setSelectedSessionId] = useState(null); + const [selectedTokenId, setSelectedTokenId] = useState(null); + const [showAddPanel, setShowAddPanel] = useState(false); + + // Fetch campaign to check GM status + const { data: campaign } = useQuery({ + queryKey: ['campaign', campaignId], + queryFn: () => api.getCampaign(campaignId!), + enabled: !!campaignId, + }); + + const isGM = campaign?.gmId === user?.id; + + // Fetch battle sessions + const { data: sessions = [] } = useQuery({ + queryKey: ['battleSessions', campaignId], + queryFn: () => api.getBattleSessions(campaignId!), + enabled: !!campaignId, + }); + + // Fetch battle maps + const { data: maps = [] } = useQuery({ + queryKey: ['battleMaps', campaignId], + queryFn: () => api.getBattleMaps(campaignId!), + enabled: !!campaignId, + }); + + // Fetch characters for adding PCs + const { data: characters = [] } = useQuery({ + queryKey: ['characters', campaignId], + queryFn: () => api.getCharacters(campaignId!), + enabled: !!campaignId && isGM, + }); + + // Fetch combatants for adding NPCs + const { data: combatants = [] } = useQuery({ + queryKey: ['combatants', campaignId], + queryFn: () => api.getCombatants(campaignId!), + enabled: !!campaignId && isGM, + }); + + // Get current session + const currentSession = sessions.find((s: BattleSession) => s.id === selectedSessionId); + + // WebSocket connection + const { + isConnected, + moveToken, + updateTokenHp, + setInitiative, + advanceRound, + } = useBattleSocket({ + sessionId: selectedSessionId || '', + onTokenMoved: useCallback((data: { tokenId: string; positionX: number; positionY: number }) => { + queryClient.setQueryData(['battleSessions', campaignId], (old: BattleSession[]) => + old?.map(session => + session.id === selectedSessionId + ? { + ...session, + tokens: session.tokens.map(token => + token.id === data.tokenId + ? { ...token, positionX: data.positionX, positionY: data.positionY } + : token + ), + } + : session + ) + ); + }, [queryClient, campaignId, selectedSessionId]), + onTokenHpChanged: useCallback((data: { tokenId: string; hpCurrent: number; hpMax: number }) => { + queryClient.setQueryData(['battleSessions', campaignId], (old: BattleSession[]) => + old?.map(session => + session.id === selectedSessionId + ? { + ...session, + tokens: session.tokens.map(token => + token.id === data.tokenId + ? { ...token, hpCurrent: data.hpCurrent, hpMax: data.hpMax } + : token + ), + } + : session + ) + ); + }, [queryClient, campaignId, selectedSessionId]), + onInitiativeSet: useCallback((data: { tokenId: string; initiative: number }) => { + queryClient.setQueryData(['battleSessions', campaignId], (old: BattleSession[]) => + old?.map(session => + session.id === selectedSessionId + ? { + ...session, + tokens: session.tokens.map(token => + token.id === data.tokenId + ? { ...token, initiative: data.initiative } + : token + ), + } + : session + ) + ); + }, [queryClient, campaignId, selectedSessionId]), + onRoundAdvanced: useCallback((data: { roundNumber: number }) => { + queryClient.setQueryData(['battleSessions', campaignId], (old: BattleSession[]) => + old?.map(session => + session.id === selectedSessionId + ? { ...session, roundNumber: data.roundNumber } + : session + ) + ); + }, [queryClient, campaignId, selectedSessionId]), + onTokenAdded: useCallback(() => { + queryClient.invalidateQueries({ queryKey: ['battleSessions', campaignId] }); + }, [queryClient, campaignId]), + onTokenRemoved: useCallback(() => { + queryClient.invalidateQueries({ queryKey: ['battleSessions', campaignId] }); + }, [queryClient, campaignId]), + }); + + // Mutations + const createSessionMutation = useMutation({ + mutationFn: (data: { name: string; mapId?: string }) => + api.createBattleSession(campaignId!, data), + onSuccess: (newSession) => { + queryClient.invalidateQueries({ queryKey: ['battleSessions', campaignId] }); + setSelectedSessionId(newSession.id); + }, + }); + + const updateSessionMutation = useMutation({ + mutationFn: ({ sessionId, data }: { sessionId: string; data: any }) => + api.updateBattleSession(campaignId!, sessionId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['battleSessions', campaignId] }); + }, + }); + + const deleteSessionMutation = useMutation({ + mutationFn: (sessionId: string) => + api.deleteBattleSession(campaignId!, sessionId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['battleSessions', campaignId] }); + setSelectedSessionId(null); + }, + }); + + const addTokenMutation = useMutation({ + mutationFn: (data: any) => + api.addBattleToken(campaignId!, selectedSessionId!, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['battleSessions', campaignId] }); + }, + }); + + const removeTokenMutation = useMutation({ + mutationFn: (tokenId: string) => + api.removeBattleToken(campaignId!, selectedSessionId!, tokenId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['battleSessions', campaignId] }); + setSelectedTokenId(null); + }, + }); + + // Auto-select first active session or first session + useEffect(() => { + if (sessions.length > 0 && !selectedSessionId) { + const activeSession = sessions.find((s: BattleSession) => s.isActive); + setSelectedSessionId(activeSession?.id || sessions[0].id); + } + }, [sessions, selectedSessionId]); + + // Handle token move + const handleMoveToken = useCallback(async (tokenId: string, positionX: number, positionY: number) => { + try { + await moveToken(tokenId, positionX, positionY); + } catch (error) { + console.error('Failed to move token:', error); + } + }, [moveToken]); + + // Add PC token + const handleAddPC = useCallback((character: Character) => { + addTokenMutation.mutate({ + characterId: character.id, + name: character.name, + positionX: 0, + positionY: 0, + hpCurrent: character.hpCurrent, + hpMax: character.hpMax, + size: 1, + }); + setShowAddPanel(false); + }, [addTokenMutation]); + + // Add NPC token + const handleAddNPC = useCallback((combatant: Combatant) => { + addTokenMutation.mutate({ + combatantId: combatant.id, + name: combatant.name, + positionX: 0, + positionY: 0, + hpCurrent: combatant.hpMax, + hpMax: combatant.hpMax, + size: 1, + }); + setShowAddPanel(false); + }, [addTokenMutation]); + + // Get selected token + const selectedToken = currentSession?.tokens.find((t: BattleToken) => t.id === selectedTokenId); + + return ( +
+ {/* Header */} +
+
+ +
+ +

Kampfbildschirm

+
+ {isConnected && ( + + Verbunden + + )} +
+ + {currentSession && ( +
+
+ Runde: {currentSession.roundNumber} +
+ {isGM && ( + + )} +
+ )} +
+ + {/* Main content */} +
+ {/* Session list sidebar */} + createSessionMutation.mutate({ name, mapId })} + onDeleteSession={(sessionId) => deleteSessionMutation.mutate(sessionId)} + onToggleActive={(sessionId, isActive) => + updateSessionMutation.mutate({ sessionId, data: { isActive } }) + } + /> + + {/* Battle canvas */} + {currentSession ? ( + + ) : ( +
+

Wähle einen Kampf aus oder erstelle einen neuen

+
+ )} + + {/* Token detail panel / Add panel */} + {currentSession && isGM && ( +
+ {/* Panel header */} +
+

+ {showAddPanel ? 'Teilnehmer hinzufügen' : 'Token-Details'} +

+ +
+ + {/* Panel content */} +
+ {showAddPanel ? ( +
+ {/* PCs */} +
+

Spielercharaktere

+
+ {characters.filter((c: Character) => c.type === 'PC').map((character: Character) => ( + + ))} + {characters.filter((c: Character) => c.type === 'PC').length === 0 && ( +

Keine Charaktere verfügbar

+ )} +
+
+ + {/* NPCs/Monsters */} +
+

NPCs & Monster

+
+ {combatants.map((combatant: Combatant) => ( + + ))} + {combatants.length === 0 && ( +

Keine NPCs verfügbar

+ )} +
+
+
+ ) : selectedToken ? ( +
+ {/* Token info */} +
+

{selectedToken.name}

+

+ {selectedToken.characterId ? 'Spielercharakter' : 'NPC/Monster'} +

+
+ + {/* HP */} +
+ +
+ updateTokenHp(selectedToken.id, parseInt(e.target.value) || 0)} + className="w-20 px-2 py-1 bg-white/5 border border-white/10 rounded text-white text-center" + /> + / + {selectedToken.hpMax} +
+
+ + {/* Initiative */} +
+ + setInitiative(selectedToken.id, parseInt(e.target.value) || 0)} + className="w-full mt-1 px-2 py-1 bg-white/5 border border-white/10 rounded text-white" + placeholder="0" + /> +
+ + {/* Position */} +
+ +

+ X: {selectedToken.positionX}, Y: {selectedToken.positionY} +

+
+ + {/* Conditions */} + {selectedToken.conditions.length > 0 && ( +
+ +
+ {selectedToken.conditions.map((condition: string, i: number) => ( + + {condition} + + ))} +
+
+ )} + + {/* Remove token */} + +
+ ) : ( +
+

Kein Token ausgewählt

+

Klicke auf ein Token oder füge neue hinzu

+
+ )} +
+
+ )} +
+
+ ); +} diff --git a/client/src/features/battle/components/battle-session-list.tsx b/client/src/features/battle/components/battle-session-list.tsx new file mode 100644 index 0000000..bf4050d --- /dev/null +++ b/client/src/features/battle/components/battle-session-list.tsx @@ -0,0 +1,179 @@ +import { useState } from 'react'; +import { Plus, Trash2, Play, Pause, Users } from 'lucide-react'; +import type { BattleSession, BattleMap } from '@/shared/types'; +import { cn } from '@/shared/lib/utils'; + +interface BattleSessionListProps { + sessions: BattleSession[]; + maps: BattleMap[]; + selectedSessionId: string | null; + isGM: boolean; + onSelectSession: (sessionId: string) => void; + onCreateSession: (name: string, mapId?: string) => void; + onDeleteSession: (sessionId: string) => void; + onToggleActive: (sessionId: string, isActive: boolean) => void; +} + +export function BattleSessionList({ + sessions, + maps, + selectedSessionId, + isGM, + onSelectSession, + onCreateSession, + onDeleteSession, + onToggleActive, +}: BattleSessionListProps) { + const [showCreateForm, setShowCreateForm] = useState(false); + const [newSessionName, setNewSessionName] = useState(''); + const [selectedMapId, setSelectedMapId] = useState(''); + + const handleCreate = () => { + if (!newSessionName.trim()) return; + onCreateSession(newSessionName.trim(), selectedMapId || undefined); + setNewSessionName(''); + setSelectedMapId(''); + setShowCreateForm(false); + }; + + return ( +
+ {/* Header */} +
+

Kämpfe

+ {isGM && ( + + )} +
+ + {/* Create form */} + {showCreateForm && isGM && ( +
+ setNewSessionName(e.target.value)} + className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-white/40 focus:outline-none focus:border-primary" + autoFocus + /> + +
+ + +
+
+ )} + + {/* Session list */} +
+ {sessions.length === 0 ? ( +
+ Keine Kämpfe vorhanden +
+ ) : ( +
+ {sessions.map((session) => ( +
onSelectSession(session.id)} + > +
+ + {session.name || 'Unbenannter Kampf'} + + {session.isActive && ( + + LIVE + + )} +
+
+ + + {session.tokens.length} + + {session.map && ( + {session.map.name} + )} +
+ + {/* Actions for GM */} + {isGM && selectedSessionId === session.id && ( +
+ + +
+ )} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/client/src/features/battle/components/token.tsx b/client/src/features/battle/components/token.tsx new file mode 100644 index 0000000..c708ee1 --- /dev/null +++ b/client/src/features/battle/components/token.tsx @@ -0,0 +1,124 @@ +import { memo, useMemo } from 'react'; +import type { BattleToken } from '@/shared/types'; +import { cn } from '@/shared/lib/utils'; + +interface TokenProps { + token: BattleToken; + cellSize: number; + isSelected?: boolean; + isGM?: boolean; + onSelect?: () => void; + onDragStart?: (e: React.DragEvent | React.TouchEvent) => void; +} + +// Token size in grid cells based on PF2e creature sizes +const SIZE_MULTIPLIER: Record = { + 1: 1, // Medium + 2: 2, // Large + 3: 3, // Huge + 4: 4, // Gargantuan +}; + +// HP percentage thresholds for visual indicators +function getHealthClass(hpCurrent: number, hpMax: number): string { + const percentage = (hpCurrent / hpMax) * 100; + if (percentage <= 0) return 'bg-gray-600'; + if (percentage <= 20) return 'bg-red-600'; + if (percentage <= 50) return 'bg-yellow-600'; + return 'bg-green-600'; +} + +export const Token = memo(function Token({ + token, + cellSize, + isSelected, + isGM, + onSelect, + onDragStart, +}: TokenProps) { + const sizeMultiplier = SIZE_MULTIPLIER[token.size] ?? 1; + const tokenSize = cellSize * sizeMultiplier; + const padding = 2; + + // Calculate position + const style = useMemo(() => ({ + left: `${token.positionX * cellSize + padding}px`, + top: `${token.positionY * cellSize + padding}px`, + width: `${tokenSize - padding * 2}px`, + height: `${tokenSize - padding * 2}px`, + }), [token.positionX, token.positionY, cellSize, tokenSize, padding]); + + // Get initials for token + const initials = useMemo(() => { + const words = token.name.split(' '); + if (words.length >= 2) { + return (words[0][0] + words[1][0]).toUpperCase(); + } + return token.name.substring(0, 2).toUpperCase(); + }, [token.name]); + + // Determine if this is a PC or NPC/Monster + const isPC = !!token.characterId; + const healthClass = getHealthClass(token.hpCurrent, token.hpMax); + + return ( +
+ {/* Background with health indicator */} +
+ + {/* HP bar */} +
+ + {/* Token content */} +
+ + {initials} + + {/* Show HP for GM, or for PCs */} + {(isGM || isPC) && ( + + {token.hpCurrent}/{token.hpMax} + + )} +
+ + {/* Initiative badge */} + {token.initiative !== null && token.initiative !== undefined && ( +
+ {token.initiative} +
+ )} + + {/* Conditions indicator */} + {token.conditions.length > 0 && ( +
+ {token.conditions.length} +
+ )} +
+ ); +}); diff --git a/client/src/features/battle/hooks/use-battle-socket.ts b/client/src/features/battle/hooks/use-battle-socket.ts new file mode 100644 index 0000000..2122ec9 --- /dev/null +++ b/client/src/features/battle/hooks/use-battle-socket.ts @@ -0,0 +1,308 @@ +import { useEffect, useRef, useCallback, useState } from 'react'; +import { io, Socket } from 'socket.io-client'; +import { api } from '@/shared/lib/api'; +import type { BattleSession, BattleToken } from '@/shared/types'; + +// Derive WebSocket URL from API URL (remove /api suffix) +const SOCKET_URL = import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:5000'; + +// Singleton socket manager to prevent multiple connections +let globalSocket: Socket | null = null; +let globalSocketRefCount = 0; +let currentSessionId: string | null = null; +let connectionAttempted = false; + +export type BattleUpdateType = + | 'token_added' + | 'token_removed' + | 'token_moved' + | 'token_updated' + | 'token_hp_changed' + | 'initiative_set' + | 'round_advanced' + | 'session_updated' + | 'session_deleted'; + +export interface BattleUpdate { + sessionId: string; + type: BattleUpdateType; + data: any; +} + +interface UseBattleSocketOptions { + sessionId: string; + onTokenAdded?: (data: { token: BattleToken }) => void; + onTokenRemoved?: (data: { tokenId: string }) => void; + onTokenMoved?: (data: { tokenId: string; positionX: number; positionY: number }) => void; + onTokenUpdated?: (data: { token: BattleToken }) => void; + onTokenHpChanged?: (data: { tokenId: string; hpCurrent: number; hpMax: number }) => void; + onInitiativeSet?: (data: { tokenId: string; initiative: number }) => void; + onRoundAdvanced?: (data: { roundNumber: number }) => void; + onSessionUpdated?: (data: { session: BattleSession }) => void; + onSessionDeleted?: () => void; +} + +export function useBattleSocket({ + sessionId, + onTokenAdded, + onTokenRemoved, + onTokenMoved, + onTokenUpdated, + onTokenHpChanged, + onInitiativeSet, + onRoundAdvanced, + onSessionUpdated, + onSessionDeleted, +}: UseBattleSocketOptions) { + const [isConnected, setIsConnected] = useState(false); + const [isGM, setIsGM] = useState(false); + const mountedRef = useRef(true); + + // Use refs for callbacks to avoid reconnection on callback changes + const callbacksRef = useRef({ + onTokenAdded, + onTokenRemoved, + onTokenMoved, + onTokenUpdated, + onTokenHpChanged, + onInitiativeSet, + onRoundAdvanced, + onSessionUpdated, + onSessionDeleted, + }); + + // Update refs when callbacks change (without causing reconnection) + useEffect(() => { + callbacksRef.current = { + onTokenAdded, + onTokenRemoved, + onTokenMoved, + onTokenUpdated, + onTokenHpChanged, + onInitiativeSet, + onRoundAdvanced, + onSessionUpdated, + onSessionDeleted, + }; + }); + + useEffect(() => { + mountedRef.current = true; + const token = api.getToken(); + if (!token || !sessionId) return; + + // Increment ref count + globalSocketRefCount++; + + // If we already have a socket for a different session, leave that room first + if (globalSocket?.connected && currentSessionId && currentSessionId !== sessionId) { + globalSocket.emit('leave_battle', { sessionId: currentSessionId }); + } + + // Update current session + currentSessionId = sessionId; + + // Create socket if it doesn't exist and we haven't already tried + if (!globalSocket && !connectionAttempted) { + connectionAttempted = true; + + globalSocket = io(`${SOCKET_URL}/battles`, { + auth: { token }, + transports: ['polling', 'websocket'], + upgrade: true, + reconnection: true, + reconnectionAttempts: 3, + reconnectionDelay: 3000, + reconnectionDelayMax: 10000, + timeout: 10000, + autoConnect: true, + }); + + globalSocket.on('connect', () => { + if (!mountedRef.current) return; + console.log('[Battle WebSocket] Connected'); + setIsConnected(true); + + // Join battle room on connect/reconnect + if (currentSessionId && globalSocket) { + globalSocket.emit('join_battle', { sessionId: currentSessionId }, (response: { success: boolean; isGM?: boolean; error?: string }) => { + if (response.success) { + console.log(`[Battle WebSocket] Joined room: ${currentSessionId}, isGM: ${response.isGM}`); + if (mountedRef.current) { + setIsGM(response.isGM ?? false); + } + } + }); + } + }); + + globalSocket.on('disconnect', (reason) => { + console.log(`[Battle WebSocket] Disconnected: ${reason}`); + if (mountedRef.current) { + setIsConnected(false); + } + }); + + globalSocket.on('connect_error', () => { + if (mountedRef.current) { + setIsConnected(false); + } + }); + + // Handle battle updates + globalSocket.on('battle_update', (update: BattleUpdate) => { + if (!mountedRef.current) return; + const callbacks = callbacksRef.current; + + switch (update.type) { + case 'token_added': + callbacks.onTokenAdded?.(update.data); + break; + case 'token_removed': + callbacks.onTokenRemoved?.(update.data); + break; + case 'token_moved': + callbacks.onTokenMoved?.(update.data); + break; + case 'token_updated': + callbacks.onTokenUpdated?.(update.data); + break; + case 'token_hp_changed': + callbacks.onTokenHpChanged?.(update.data); + break; + case 'initiative_set': + callbacks.onInitiativeSet?.(update.data); + break; + case 'round_advanced': + callbacks.onRoundAdvanced?.(update.data); + break; + case 'session_updated': + callbacks.onSessionUpdated?.(update.data); + break; + case 'session_deleted': + callbacks.onSessionDeleted?.(); + break; + } + }); + } else if (globalSocket?.connected) { + // Socket already exists and connected, just join the room + globalSocket.emit('join_battle', { sessionId }, (response: { success: boolean; isGM?: boolean; error?: string }) => { + if (response.success) { + console.log(`[Battle WebSocket] Joined room: ${sessionId}, isGM: ${response.isGM}`); + if (mountedRef.current) { + setIsGM(response.isGM ?? false); + } + } + }); + } + + // Cleanup on unmount + return () => { + mountedRef.current = false; + globalSocketRefCount--; + + if (globalSocket && currentSessionId === sessionId) { + globalSocket.emit('leave_battle', { sessionId }); + } + + // Only disconnect socket if no more refs + if (globalSocketRefCount <= 0 && globalSocket) { + globalSocket.disconnect(); + globalSocket = null; + currentSessionId = null; + connectionAttempted = false; + } + }; + }, [sessionId]); + + // Socket actions + const moveToken = useCallback((tokenId: string, positionX: number, positionY: number) => { + if (!globalSocket?.connected || !currentSessionId) return Promise.reject('Not connected'); + + return new Promise((resolve, reject) => { + globalSocket!.emit('move_token', { + sessionId: currentSessionId, + tokenId, + positionX, + positionY, + }, (response: { success: boolean; error?: string }) => { + if (response.success) { + resolve(response); + } else { + reject(response.error); + } + }); + }); + }, []); + + const updateTokenHp = useCallback((tokenId: string, hpCurrent: number) => { + if (!globalSocket?.connected || !currentSessionId) return Promise.reject('Not connected'); + + return new Promise((resolve, reject) => { + globalSocket!.emit('update_token_hp', { + sessionId: currentSessionId, + tokenId, + hpCurrent, + }, (response: { success: boolean; error?: string }) => { + if (response.success) { + resolve(response); + } else { + reject(response.error); + } + }); + }); + }, []); + + const setInitiative = useCallback((tokenId: string, initiative: number) => { + if (!globalSocket?.connected || !currentSessionId) return Promise.reject('Not connected'); + + return new Promise((resolve, reject) => { + globalSocket!.emit('set_initiative', { + sessionId: currentSessionId, + tokenId, + initiative, + }, (response: { success: boolean; error?: string }) => { + if (response.success) { + resolve(response); + } else { + reject(response.error); + } + }); + }); + }, []); + + const advanceRound = useCallback(() => { + if (!globalSocket?.connected || !currentSessionId) return Promise.reject('Not connected'); + + return new Promise((resolve, reject) => { + globalSocket!.emit('advance_round', { + sessionId: currentSessionId, + }, (response: { success: boolean; error?: string }) => { + if (response.success) { + resolve(response); + } else { + reject(response.error); + } + }); + }); + }, []); + + const reconnect = useCallback(() => { + if (globalSocket) { + globalSocket.connect(); + } else { + connectionAttempted = false; + } + }, []); + + return { + socket: globalSocket, + isConnected, + isGM, + reconnect, + moveToken, + updateTokenHp, + setInitiative, + advanceRound, + }; +} diff --git a/client/src/features/battle/index.ts b/client/src/features/battle/index.ts new file mode 100644 index 0000000..8f7a563 --- /dev/null +++ b/client/src/features/battle/index.ts @@ -0,0 +1,5 @@ +export { BattlePage } from './components/battle-page'; +export { BattleCanvas } from './components/battle-canvas'; +export { BattleSessionList } from './components/battle-session-list'; +export { Token } from './components/token'; +export { useBattleSocket } from './hooks/use-battle-socket'; diff --git a/client/src/shared/lib/api.ts b/client/src/shared/lib/api.ts index c167383..3ec07ba 100644 --- a/client/src/shared/lib/api.ts +++ b/client/src/shared/lib/api.ts @@ -474,6 +474,218 @@ class ApiClient { const response = await this.client.delete(`/campaigns/${campaignId}/characters/${characterId}/alchemy/prepared/${itemId}`); return response.data; } + + // ========================================== + // BATTLE SYSTEM + // ========================================== + + // Battle Sessions + async getBattleSessions(campaignId: string) { + const response = await this.client.get(`/campaigns/${campaignId}/battles`); + return response.data; + } + + async getBattleSession(campaignId: string, sessionId: string) { + const response = await this.client.get(`/campaigns/${campaignId}/battles/${sessionId}`); + return response.data; + } + + async createBattleSession(campaignId: string, data: { + name?: string; + mapId?: string; + isActive?: boolean; + }) { + const response = await this.client.post(`/campaigns/${campaignId}/battles`, data); + return response.data; + } + + async updateBattleSession(campaignId: string, sessionId: string, data: { + name?: string; + mapId?: string; + isActive?: boolean; + roundNumber?: number; + }) { + const response = await this.client.put(`/campaigns/${campaignId}/battles/${sessionId}`, data); + return response.data; + } + + async deleteBattleSession(campaignId: string, sessionId: string) { + const response = await this.client.delete(`/campaigns/${campaignId}/battles/${sessionId}`); + return response.data; + } + + // Battle Tokens + async addBattleToken(campaignId: string, sessionId: string, data: { + combatantId?: string; + characterId?: string; + name: string; + positionX: number; + positionY: number; + hpCurrent: number; + hpMax: number; + initiative?: number; + conditions?: string[]; + size?: number; + }) { + const response = await this.client.post(`/campaigns/${campaignId}/battles/${sessionId}/tokens`, data); + return response.data; + } + + async updateBattleToken(campaignId: string, sessionId: string, tokenId: string, data: { + name?: string; + positionX?: number; + positionY?: number; + hpCurrent?: number; + hpMax?: number; + initiative?: number; + conditions?: string[]; + size?: number; + }) { + const response = await this.client.patch(`/campaigns/${campaignId}/battles/${sessionId}/tokens/${tokenId}`, data); + return response.data; + } + + async removeBattleToken(campaignId: string, sessionId: string, tokenId: string) { + const response = await this.client.delete(`/campaigns/${campaignId}/battles/${sessionId}/tokens/${tokenId}`); + return response.data; + } + + async moveBattleToken(campaignId: string, sessionId: string, tokenId: string, positionX: number, positionY: number) { + const response = await this.client.patch(`/campaigns/${campaignId}/battles/${sessionId}/tokens/${tokenId}/move`, { + positionX, + positionY, + }); + return response.data; + } + + async setTokenInitiative(campaignId: string, sessionId: string, tokenId: string, initiative: number) { + const response = await this.client.patch(`/campaigns/${campaignId}/battles/${sessionId}/tokens/${tokenId}/initiative`, { + initiative, + }); + return response.data; + } + + async advanceBattleRound(campaignId: string, sessionId: string) { + const response = await this.client.post(`/campaigns/${campaignId}/battles/${sessionId}/advance-round`); + return response.data; + } + + // Battle Maps + async getBattleMaps(campaignId: string) { + const response = await this.client.get(`/campaigns/${campaignId}/battle-maps`); + return response.data; + } + + async getBattleMap(campaignId: string, mapId: string) { + const response = await this.client.get(`/campaigns/${campaignId}/battle-maps/${mapId}`); + return response.data; + } + + async uploadBattleMap(campaignId: string, data: { + name: string; + gridSizeX?: number; + gridSizeY?: number; + gridOffsetX?: number; + gridOffsetY?: number; + image: File; + }) { + const formData = new FormData(); + formData.append('name', data.name); + if (data.gridSizeX !== undefined) formData.append('gridSizeX', data.gridSizeX.toString()); + if (data.gridSizeY !== undefined) formData.append('gridSizeY', data.gridSizeY.toString()); + if (data.gridOffsetX !== undefined) formData.append('gridOffsetX', data.gridOffsetX.toString()); + if (data.gridOffsetY !== undefined) formData.append('gridOffsetY', data.gridOffsetY.toString()); + formData.append('image', data.image); + + const response = await this.client.post(`/campaigns/${campaignId}/battle-maps`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return response.data; + } + + async updateBattleMap(campaignId: string, mapId: string, data: { + name?: string; + gridSizeX?: number; + gridSizeY?: number; + gridOffsetX?: number; + gridOffsetY?: number; + }) { + const response = await this.client.put(`/campaigns/${campaignId}/battle-maps/${mapId}`, data); + return response.data; + } + + async deleteBattleMap(campaignId: string, mapId: string) { + const response = await this.client.delete(`/campaigns/${campaignId}/battle-maps/${mapId}`); + return response.data; + } + + // Combatants (NPC/Monster Templates) + async getCombatants(campaignId: string) { + const response = await this.client.get(`/campaigns/${campaignId}/combatants`); + return response.data; + } + + async getCombatant(campaignId: string, combatantId: string) { + const response = await this.client.get(`/campaigns/${campaignId}/combatants/${combatantId}`); + return response.data; + } + + async createCombatant(campaignId: string, data: { + name: string; + type: 'PC' | 'NPC' | 'MONSTER'; + level: number; + hpMax: number; + ac: number; + fortitude: number; + reflex: number; + will: number; + perception: number; + speed?: number; + avatarUrl?: string; + description?: string; + abilities?: Array<{ + name: string; + actionCost: number; + actionType: 'ACTION' | 'REACTION' | 'FREE'; + description: string; + damage?: string; + traits?: string[]; + }>; + }) { + const response = await this.client.post(`/campaigns/${campaignId}/combatants`, data); + return response.data; + } + + async updateCombatant(campaignId: string, combatantId: string, data: { + name?: string; + type?: 'PC' | 'NPC' | 'MONSTER'; + level?: number; + hpMax?: number; + ac?: number; + fortitude?: number; + reflex?: number; + will?: number; + perception?: number; + speed?: number; + avatarUrl?: string; + description?: string; + abilities?: Array<{ + name: string; + actionCost: number; + actionType: 'ACTION' | 'REACTION' | 'FREE'; + description: string; + damage?: string; + traits?: string[]; + }>; + }) { + const response = await this.client.put(`/campaigns/${campaignId}/combatants/${combatantId}`, data); + return response.data; + } + + async deleteCombatant(campaignId: string, combatantId: string) { + const response = await this.client.delete(`/campaigns/${campaignId}/combatants/${combatantId}`); + return response.data; + } } export const api = new ApiClient(); diff --git a/server/package-lock.json b/server/package-lock.json index 570107a..41250c4 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -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", diff --git a/server/package.json b/server/package.json index 067db28..bf6371b 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/app.module.ts b/server/src/app.module.ts index a79a99f..1248abf 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -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 diff --git a/server/src/modules/battle/battle-maps.controller.ts b/server/src/modules/battle/battle-maps.controller.ts new file mode 100644 index 0000000..db47a71 --- /dev/null +++ b/server/src/modules/battle/battle-maps.controller.ts @@ -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); + } +} diff --git a/server/src/modules/battle/battle-maps.service.ts b/server/src/modules/battle/battle-maps.service.ts new file mode 100644 index 0000000..84c0d2d --- /dev/null +++ b/server/src/modules/battle/battle-maps.service.ts @@ -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; + } +} diff --git a/server/src/modules/battle/battle.controller.ts b/server/src/modules/battle/battle.controller.ts new file mode 100644 index 0000000..eaad2e2 --- /dev/null +++ b/server/src/modules/battle/battle.controller.ts @@ -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); + } +} diff --git a/server/src/modules/battle/battle.gateway.ts b/server/src/modules/battle/battle.gateway.ts new file mode 100644 index 0000000..13bd2f2 --- /dev/null +++ b/server/src/modules/battle/battle.gateway.ts @@ -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>(); // sessionId -> Set + + 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('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; + } +} diff --git a/server/src/modules/battle/battle.module.ts b/server/src/modules/battle/battle.module.ts new file mode 100644 index 0000000..3a34a8b --- /dev/null +++ b/server/src/modules/battle/battle.module.ts @@ -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('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 {} diff --git a/server/src/modules/battle/battle.service.ts b/server/src/modules/battle/battle.service.ts new file mode 100644 index 0000000..2a1b662 --- /dev/null +++ b/server/src/modules/battle/battle.service.ts @@ -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; + } +} diff --git a/server/src/modules/battle/combatants.controller.ts b/server/src/modules/battle/combatants.controller.ts new file mode 100644 index 0000000..e6d11c7 --- /dev/null +++ b/server/src/modules/battle/combatants.controller.ts @@ -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); + } +} diff --git a/server/src/modules/battle/combatants.service.ts b/server/src/modules/battle/combatants.service.ts new file mode 100644 index 0000000..d4e1a52 --- /dev/null +++ b/server/src/modules/battle/combatants.service.ts @@ -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, 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; + } +} diff --git a/server/src/modules/battle/dto/create-battle-map.dto.ts b/server/src/modules/battle/dto/create-battle-map.dto.ts new file mode 100644 index 0000000..7d534a6 --- /dev/null +++ b/server/src/modules/battle/dto/create-battle-map.dto.ts @@ -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; +} diff --git a/server/src/modules/battle/dto/create-battle-session.dto.ts b/server/src/modules/battle/dto/create-battle-session.dto.ts new file mode 100644 index 0000000..37de3fc --- /dev/null +++ b/server/src/modules/battle/dto/create-battle-session.dto.ts @@ -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; +} diff --git a/server/src/modules/battle/dto/create-battle-token.dto.ts b/server/src/modules/battle/dto/create-battle-token.dto.ts new file mode 100644 index 0000000..26b7ae7 --- /dev/null +++ b/server/src/modules/battle/dto/create-battle-token.dto.ts @@ -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; +} diff --git a/server/src/modules/battle/dto/create-combatant.dto.ts b/server/src/modules/battle/dto/create-combatant.dto.ts new file mode 100644 index 0000000..1a6b92c --- /dev/null +++ b/server/src/modules/battle/dto/create-combatant.dto.ts @@ -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[] = []; +} diff --git a/server/src/modules/battle/dto/index.ts b/server/src/modules/battle/dto/index.ts new file mode 100644 index 0000000..28ef933 --- /dev/null +++ b/server/src/modules/battle/dto/index.ts @@ -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'; diff --git a/server/src/modules/battle/dto/update-battle-map.dto.ts b/server/src/modules/battle/dto/update-battle-map.dto.ts new file mode 100644 index 0000000..99c74bd --- /dev/null +++ b/server/src/modules/battle/dto/update-battle-map.dto.ts @@ -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; +} diff --git a/server/src/modules/battle/dto/update-battle-session.dto.ts b/server/src/modules/battle/dto/update-battle-session.dto.ts new file mode 100644 index 0000000..af3f068 --- /dev/null +++ b/server/src/modules/battle/dto/update-battle-session.dto.ts @@ -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; +} diff --git a/server/src/modules/battle/dto/update-battle-token.dto.ts b/server/src/modules/battle/dto/update-battle-token.dto.ts new file mode 100644 index 0000000..775e69e --- /dev/null +++ b/server/src/modules/battle/dto/update-battle-token.dto.ts @@ -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; +}