feat: Add battle screen with real-time sync (Phase 1 MVP)
All checks were successful
Deploy Dimension47 / deploy (push) Successful in 35s
All checks were successful
Deploy Dimension47 / deploy (push) Successful in 35s
- Add battle module with sessions, maps, tokens, and combatants - Implement WebSocket gateway for real-time battle updates - Add map upload with configurable grid system - Create battle canvas with token rendering and drag support - Support PC tokens from characters and NPC tokens from templates - Add initiative tracking and round management - GM-only controls for token manipulation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|||||||
import { LoginPage, RegisterPage, useAuthStore } from '@/features/auth';
|
import { LoginPage, RegisterPage, useAuthStore } from '@/features/auth';
|
||||||
import { CampaignsPage, CampaignDetailPage } from '@/features/campaigns';
|
import { CampaignsPage, CampaignDetailPage } from '@/features/campaigns';
|
||||||
import { CharacterSheetPage } from '@/features/characters';
|
import { CharacterSheetPage } from '@/features/characters';
|
||||||
|
import { BattlePage } from '@/features/battle';
|
||||||
import { ProtectedRoute } from '@/shared/components/protected-route';
|
import { ProtectedRoute } from '@/shared/components/protected-route';
|
||||||
import { Layout } from '@/shared/components/layout';
|
import { Layout } from '@/shared/components/layout';
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ function AppContent() {
|
|||||||
<Route path="/" element={<CampaignsPage />} />
|
<Route path="/" element={<CampaignsPage />} />
|
||||||
<Route path="/campaigns/:id" element={<CampaignDetailPage />} />
|
<Route path="/campaigns/:id" element={<CampaignDetailPage />} />
|
||||||
<Route path="/campaigns/:id/characters/:characterId" element={<CharacterSheetPage />} />
|
<Route path="/campaigns/:id/characters/:characterId" element={<CharacterSheetPage />} />
|
||||||
|
<Route path="/campaigns/:id/battle" element={<BattlePage />} />
|
||||||
<Route path="/library" element={<div>Library (TODO)</div>} />
|
<Route path="/library" element={<div>Library (TODO)</div>} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
238
client/src/features/battle/components/battle-canvas.tsx
Normal file
238
client/src/features/battle/components/battle-canvas.tsx
Normal file
@@ -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<HTMLDivElement>(null);
|
||||||
|
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
||||||
|
const [draggedToken, setDraggedToken] = useState<string | null>(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(
|
||||||
|
<line
|
||||||
|
key={`v-${x}`}
|
||||||
|
x1={x * cellSize + map.gridOffsetX}
|
||||||
|
y1={map.gridOffsetY}
|
||||||
|
x2={x * cellSize + map.gridOffsetX}
|
||||||
|
y2={canvasSize.height + map.gridOffsetY}
|
||||||
|
stroke="rgba(255, 255, 255, 0.3)"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal lines
|
||||||
|
for (let y = 0; y <= map.gridSizeY; y++) {
|
||||||
|
lines.push(
|
||||||
|
<line
|
||||||
|
key={`h-${y}`}
|
||||||
|
x1={map.gridOffsetX}
|
||||||
|
y1={y * cellSize + map.gridOffsetY}
|
||||||
|
x2={canvasSize.width + map.gridOffsetX}
|
||||||
|
y2={y * cellSize + map.gridOffsetY}
|
||||||
|
stroke="rgba(255, 255, 255, 0.3)"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}, [map, showGrid, cellSize, canvasSize]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col bg-black/50 rounded-lg overflow-hidden">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-2 p-2 bg-black/30 border-b border-white/10">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowGrid(!showGrid)}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1 text-sm rounded transition-colors',
|
||||||
|
showGrid ? 'bg-primary text-white' : 'bg-white/10 text-white/70 hover:bg-white/20',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Grid
|
||||||
|
</button>
|
||||||
|
{map && (
|
||||||
|
<span className="text-sm text-white/50">
|
||||||
|
{map.gridSizeX} x {map.gridSizeY} ({cellSize}px)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Canvas container */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex-1 overflow-auto flex items-center justify-center p-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative bg-gray-900 shadow-2xl"
|
||||||
|
style={{
|
||||||
|
width: canvasSize.width + (map?.gridOffsetX ?? 0),
|
||||||
|
height: canvasSize.height + (map?.gridOffsetY ?? 0),
|
||||||
|
}}
|
||||||
|
onClick={handleCanvasClick}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
>
|
||||||
|
{/* Map background */}
|
||||||
|
{map && (
|
||||||
|
<img
|
||||||
|
src={`${API_URL}${map.imageUrl}`}
|
||||||
|
alt={map.name}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Grid overlay */}
|
||||||
|
<svg
|
||||||
|
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||||
|
style={{ zIndex: 5 }}
|
||||||
|
>
|
||||||
|
{gridLines}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Tokens */}
|
||||||
|
<div
|
||||||
|
className="absolute"
|
||||||
|
style={{
|
||||||
|
left: map?.gridOffsetX ?? 0,
|
||||||
|
top: map?.gridOffsetY ?? 0,
|
||||||
|
width: canvasSize.width,
|
||||||
|
height: canvasSize.height,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{session.tokens.map((token) => (
|
||||||
|
<Token
|
||||||
|
key={token.id}
|
||||||
|
token={token}
|
||||||
|
cellSize={cellSize}
|
||||||
|
isSelected={token.id === selectedTokenId}
|
||||||
|
isGM={isGM}
|
||||||
|
onSelect={() => onSelectToken(token.id)}
|
||||||
|
onDragStart={handleDragStart(token.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* No map placeholder */}
|
||||||
|
{!map && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-white/50">
|
||||||
|
<p>Keine Karte ausgewählt</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
450
client/src/features/battle/components/battle-page.tsx
Normal file
450
client/src/features/battle/components/battle-page.tsx
Normal file
@@ -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<string | null>(null);
|
||||||
|
const [selectedTokenId, setSelectedTokenId] = useState<string | null>(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 (
|
||||||
|
<div className="h-screen flex flex-col bg-gray-900">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="flex items-center justify-between px-4 py-3 bg-black/50 border-b border-white/10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/campaigns/${campaignId}`)}
|
||||||
|
className="p-2 rounded-lg bg-white/5 hover:bg-white/10 text-white/70 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Swords className="w-5 h-5 text-primary" />
|
||||||
|
<h1 className="text-lg font-semibold text-white">Kampfbildschirm</h1>
|
||||||
|
</div>
|
||||||
|
{isConnected && (
|
||||||
|
<span className="px-2 py-0.5 text-[10px] font-medium bg-green-500/20 text-green-400 rounded">
|
||||||
|
Verbunden
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentSession && (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-sm text-white/70">
|
||||||
|
Runde: <span className="font-bold text-white">{currentSession.roundNumber}</span>
|
||||||
|
</div>
|
||||||
|
{isGM && (
|
||||||
|
<button
|
||||||
|
onClick={() => advanceRound()}
|
||||||
|
className="px-3 py-1.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/80 transition-colors"
|
||||||
|
>
|
||||||
|
Nächste Runde
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* Session list sidebar */}
|
||||||
|
<BattleSessionList
|
||||||
|
sessions={sessions}
|
||||||
|
maps={maps}
|
||||||
|
selectedSessionId={selectedSessionId}
|
||||||
|
isGM={isGM}
|
||||||
|
onSelectSession={setSelectedSessionId}
|
||||||
|
onCreateSession={(name, mapId) => createSessionMutation.mutate({ name, mapId })}
|
||||||
|
onDeleteSession={(sessionId) => deleteSessionMutation.mutate(sessionId)}
|
||||||
|
onToggleActive={(sessionId, isActive) =>
|
||||||
|
updateSessionMutation.mutate({ sessionId, data: { isActive } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Battle canvas */}
|
||||||
|
{currentSession ? (
|
||||||
|
<BattleCanvas
|
||||||
|
session={currentSession}
|
||||||
|
map={currentSession.map || null}
|
||||||
|
isGM={isGM}
|
||||||
|
selectedTokenId={selectedTokenId}
|
||||||
|
onSelectToken={setSelectedTokenId}
|
||||||
|
onMoveToken={handleMoveToken}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-white/50">
|
||||||
|
<p>Wähle einen Kampf aus oder erstelle einen neuen</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Token detail panel / Add panel */}
|
||||||
|
{currentSession && isGM && (
|
||||||
|
<div className="w-72 bg-black/30 border-l border-white/10 flex flex-col">
|
||||||
|
{/* Panel header */}
|
||||||
|
<div className="p-3 border-b border-white/10 flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-white">
|
||||||
|
{showAddPanel ? 'Teilnehmer hinzufügen' : 'Token-Details'}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddPanel(!showAddPanel)}
|
||||||
|
className={cn(
|
||||||
|
'p-1.5 rounded-lg transition-colors',
|
||||||
|
showAddPanel
|
||||||
|
? 'bg-white/10 text-white/70'
|
||||||
|
: 'bg-primary/20 text-primary hover:bg-primary/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Panel content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
|
{showAddPanel ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* PCs */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-white/70 mb-2">Spielercharaktere</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{characters.filter((c: Character) => c.type === 'PC').map((character: Character) => (
|
||||||
|
<button
|
||||||
|
key={character.id}
|
||||||
|
onClick={() => handleAddPC(character)}
|
||||||
|
className="w-full flex items-center gap-2 p-2 rounded-lg bg-blue-500/10 hover:bg-blue-500/20 text-blue-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
<span className="text-sm truncate">{character.name}</span>
|
||||||
|
<ChevronRight className="w-4 h-4 ml-auto opacity-50" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{characters.filter((c: Character) => c.type === 'PC').length === 0 && (
|
||||||
|
<p className="text-xs text-white/40">Keine Charaktere verfügbar</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* NPCs/Monsters */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-white/70 mb-2">NPCs & Monster</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{combatants.map((combatant: Combatant) => (
|
||||||
|
<button
|
||||||
|
key={combatant.id}
|
||||||
|
onClick={() => handleAddNPC(combatant)}
|
||||||
|
className="w-full flex items-center gap-2 p-2 rounded-lg bg-red-500/10 hover:bg-red-500/20 text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Swords className="w-4 h-4" />
|
||||||
|
<span className="text-sm truncate">{combatant.name}</span>
|
||||||
|
<span className="text-xs opacity-50">Lvl {combatant.level}</span>
|
||||||
|
<ChevronRight className="w-4 h-4 ml-auto opacity-50" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{combatants.length === 0 && (
|
||||||
|
<p className="text-xs text-white/40">Keine NPCs verfügbar</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : selectedToken ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Token info */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-lg font-semibold text-white">{selectedToken.name}</h4>
|
||||||
|
<p className="text-sm text-white/50">
|
||||||
|
{selectedToken.characterId ? 'Spielercharakter' : 'NPC/Monster'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* HP */}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-white/70">HP</label>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={selectedToken.hpCurrent}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<span className="text-white/50">/</span>
|
||||||
|
<span className="text-white">{selectedToken.hpMax}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Initiative */}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-white/70">Initiative</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={selectedToken.initiative ?? ''}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Position */}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-white/70">Position</label>
|
||||||
|
<p className="text-white">
|
||||||
|
X: {selectedToken.positionX}, Y: {selectedToken.positionY}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conditions */}
|
||||||
|
{selectedToken.conditions.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-white/70">Zustände</label>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{selectedToken.conditions.map((condition: string, i: number) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="px-2 py-0.5 text-xs bg-yellow-500/20 text-yellow-400 rounded"
|
||||||
|
>
|
||||||
|
{condition}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Remove token */}
|
||||||
|
<button
|
||||||
|
onClick={() => removeTokenMutation.mutate(selectedToken.id)}
|
||||||
|
className="w-full px-3 py-2 bg-red-500/20 text-red-400 rounded-lg text-sm hover:bg-red-500/30 transition-colors"
|
||||||
|
>
|
||||||
|
Vom Kampf entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-white/40 py-8">
|
||||||
|
<p>Kein Token ausgewählt</p>
|
||||||
|
<p className="text-xs mt-1">Klicke auf ein Token oder füge neue hinzu</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
179
client/src/features/battle/components/battle-session-list.tsx
Normal file
179
client/src/features/battle/components/battle-session-list.tsx
Normal file
@@ -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<string>('');
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
if (!newSessionName.trim()) return;
|
||||||
|
onCreateSession(newSessionName.trim(), selectedMapId || undefined);
|
||||||
|
setNewSessionName('');
|
||||||
|
setSelectedMapId('');
|
||||||
|
setShowCreateForm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-64 bg-black/30 border-r border-white/10 flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-3 border-b border-white/10 flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold text-white">Kämpfe</h2>
|
||||||
|
{isGM && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(!showCreateForm)}
|
||||||
|
className="p-1.5 rounded-lg bg-primary/20 hover:bg-primary/30 text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create form */}
|
||||||
|
{showCreateForm && isGM && (
|
||||||
|
<div className="p-3 border-b border-white/10 space-y-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Kampfname..."
|
||||||
|
value={newSessionName}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={selectedMapId}
|
||||||
|
onChange={(e) => setSelectedMapId(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary"
|
||||||
|
>
|
||||||
|
<option value="">Keine Karte</option>
|
||||||
|
{maps.map((map) => (
|
||||||
|
<option key={map.id} value={map.id}>
|
||||||
|
{map.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={!newSessionName.trim()}
|
||||||
|
className="flex-1 px-3 py-1.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Erstellen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(false)}
|
||||||
|
className="px-3 py-1.5 bg-white/10 text-white rounded-lg text-sm hover:bg-white/20 transition-colors"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Session list */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-white/50 text-sm">
|
||||||
|
Keine Kämpfe vorhanden
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
className={cn(
|
||||||
|
'p-3 rounded-lg cursor-pointer transition-colors',
|
||||||
|
selectedSessionId === session.id
|
||||||
|
? 'bg-primary/30 border border-primary'
|
||||||
|
: 'bg-white/5 border border-transparent hover:bg-white/10',
|
||||||
|
)}
|
||||||
|
onClick={() => onSelectSession(session.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium text-white truncate">
|
||||||
|
{session.name || 'Unbenannter Kampf'}
|
||||||
|
</span>
|
||||||
|
{session.isActive && (
|
||||||
|
<span className="px-1.5 py-0.5 text-[10px] font-bold bg-green-500 text-white rounded">
|
||||||
|
LIVE
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-1 text-xs text-white/50">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Users className="w-3 h-3" />
|
||||||
|
{session.tokens.length}
|
||||||
|
</span>
|
||||||
|
{session.map && (
|
||||||
|
<span className="truncate">{session.map.name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions for GM */}
|
||||||
|
{isGM && selectedSessionId === session.id && (
|
||||||
|
<div className="flex gap-1 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleActive(session.id, !session.isActive);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 flex items-center justify-center gap-1 px-2 py-1 rounded text-xs transition-colors',
|
||||||
|
session.isActive
|
||||||
|
? 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30'
|
||||||
|
: 'bg-green-500/20 text-green-400 hover:bg-green-500/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{session.isActive ? (
|
||||||
|
<>
|
||||||
|
<Pause className="w-3 h-3" /> Pausieren
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play className="w-3 h-3" /> Starten
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (confirm('Kampf wirklich löschen?')) {
|
||||||
|
onDeleteSession(session.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-2 py-1 rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
client/src/features/battle/components/token.tsx
Normal file
124
client/src/features/battle/components/token.tsx
Normal file
@@ -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<number, number> = {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute rounded-full cursor-pointer transition-all duration-100',
|
||||||
|
'flex items-center justify-center',
|
||||||
|
'border-2 shadow-lg',
|
||||||
|
isSelected ? 'ring-2 ring-primary ring-offset-2 ring-offset-black z-20' : 'z-10',
|
||||||
|
isPC ? 'border-blue-400' : 'border-red-400',
|
||||||
|
'hover:scale-110',
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
onClick={onSelect}
|
||||||
|
draggable={isGM}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onTouchStart={onDragStart}
|
||||||
|
title={`${token.name}${token.initiative !== null ? ` (Init: ${token.initiative})` : ''}`}
|
||||||
|
>
|
||||||
|
{/* Background with health indicator */}
|
||||||
|
<div className={cn(
|
||||||
|
'absolute inset-0 rounded-full',
|
||||||
|
isPC ? 'bg-blue-900' : 'bg-red-900',
|
||||||
|
)} />
|
||||||
|
|
||||||
|
{/* HP bar */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute bottom-0 left-0 right-0 h-1 rounded-b-full transition-all',
|
||||||
|
healthClass,
|
||||||
|
)}
|
||||||
|
style={{ width: `${Math.max(0, Math.min(100, (token.hpCurrent / token.hpMax) * 100))}%` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Token content */}
|
||||||
|
<div className="relative z-10 flex flex-col items-center justify-center">
|
||||||
|
<span className="text-xs font-bold text-white drop-shadow-lg">
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
{/* Show HP for GM, or for PCs */}
|
||||||
|
{(isGM || isPC) && (
|
||||||
|
<span className="text-[10px] text-white/80">
|
||||||
|
{token.hpCurrent}/{token.hpMax}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Initiative badge */}
|
||||||
|
{token.initiative !== null && token.initiative !== undefined && (
|
||||||
|
<div className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-primary text-[10px] font-bold text-white flex items-center justify-center">
|
||||||
|
{token.initiative}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Conditions indicator */}
|
||||||
|
{token.conditions.length > 0 && (
|
||||||
|
<div className="absolute -bottom-1 -right-1 w-4 h-4 rounded-full bg-yellow-500 text-[10px] font-bold text-black flex items-center justify-center">
|
||||||
|
{token.conditions.length}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
308
client/src/features/battle/hooks/use-battle-socket.ts
Normal file
308
client/src/features/battle/hooks/use-battle-socket.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
5
client/src/features/battle/index.ts
Normal file
5
client/src/features/battle/index.ts
Normal file
@@ -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';
|
||||||
@@ -474,6 +474,218 @@ class ApiClient {
|
|||||||
const response = await this.client.delete(`/campaigns/${campaignId}/characters/${characterId}/alchemy/prepared/${itemId}`);
|
const response = await this.client.delete(`/campaigns/${campaignId}/characters/${characterId}/alchemy/prepared/${itemId}`);
|
||||||
return response.data;
|
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();
|
export const api = new ApiClient();
|
||||||
|
|||||||
72
server/package-lock.json
generated
72
server/package-lock.json
generated
@@ -17,6 +17,7 @@
|
|||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/platform-socket.io": "^11.1.12",
|
"@nestjs/platform-socket.io": "^11.1.12",
|
||||||
|
"@nestjs/serve-static": "^5.0.4",
|
||||||
"@nestjs/swagger": "^11.2.5",
|
"@nestjs/swagger": "^11.2.5",
|
||||||
"@nestjs/websockets": "^11.1.12",
|
"@nestjs/websockets": "^11.1.12",
|
||||||
"@prisma/adapter-pg": "^7.2.0",
|
"@prisma/adapter-pg": "^7.2.0",
|
||||||
@@ -259,7 +260,6 @@
|
|||||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.28.6",
|
"@babel/code-frame": "^7.28.6",
|
||||||
"@babel/generator": "^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",
|
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.2.tgz",
|
||||||
"integrity": "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==",
|
"integrity": "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@electric-sql/pglite-socket": {
|
"node_modules/@electric-sql/pglite-socket": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
@@ -2724,7 +2723,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.12.tgz",
|
||||||
"integrity": "sha512-v6U3O01YohHO+IE3EIFXuRuu3VJILWzyMmSYZXpyBbnp0hk0mFyHxK2w3dF4I5WnbwiRbWlEXdeXFvPQ7qaZzw==",
|
"integrity": "sha512-v6U3O01YohHO+IE3EIFXuRuu3VJILWzyMmSYZXpyBbnp0hk0mFyHxK2w3dF4I5WnbwiRbWlEXdeXFvPQ7qaZzw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"file-type": "21.3.0",
|
"file-type": "21.3.0",
|
||||||
"iterare": "1.2.1",
|
"iterare": "1.2.1",
|
||||||
@@ -2784,7 +2782,6 @@
|
|||||||
"integrity": "sha512-97DzTYMf5RtGAVvX1cjwpKRiCUpkeQ9CCzSAenqkAhOmNVVFaApbhuw+xrDt13rsCa2hHVOYPrV4dBgOYMJjsA==",
|
"integrity": "sha512-97DzTYMf5RtGAVvX1cjwpKRiCUpkeQ9CCzSAenqkAhOmNVVFaApbhuw+xrDt13rsCa2hHVOYPrV4dBgOYMJjsA==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/opencollective": "0.4.1",
|
"@nuxt/opencollective": "0.4.1",
|
||||||
"fast-safe-stringify": "2.1.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",
|
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.12.tgz",
|
||||||
"integrity": "sha512-GYK/vHI0SGz5m8mxr7v3Urx8b9t78Cf/dj5aJMZlGd9/1D9OI1hAl00BaphjEXINUJ/BQLxIlF2zUjrYsd6enQ==",
|
"integrity": "sha512-GYK/vHI0SGz5m8mxr7v3Urx8b9t78Cf/dj5aJMZlGd9/1D9OI1hAl00BaphjEXINUJ/BQLxIlF2zUjrYsd6enQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
@@ -2890,7 +2886,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.12.tgz",
|
||||||
"integrity": "sha512-1itTTYsAZecrq2NbJOkch32y8buLwN7UpcNRdJrhlS+ovJ5GxLx3RyJ3KylwBhbYnO5AeYyL1U/i4W5mg/4qDA==",
|
"integrity": "sha512-1itTTYsAZecrq2NbJOkch32y8buLwN7UpcNRdJrhlS+ovJ5GxLx3RyJ3KylwBhbYnO5AeYyL1U/i4W5mg/4qDA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"socket.io": "4.8.3",
|
"socket.io": "4.8.3",
|
||||||
"tslib": "2.8.1"
|
"tslib": "2.8.1"
|
||||||
@@ -3003,6 +2998,33 @@
|
|||||||
"tslib": "^2.1.0"
|
"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": {
|
"node_modules/@nestjs/swagger": {
|
||||||
"version": "11.2.5",
|
"version": "11.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.2.5.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.12.tgz",
|
||||||
"integrity": "sha512-ulSOYcgosx1TqY425cRC5oXtAu1R10+OSmVfgyR9ueR25k4luekURt8dzAZxhxSCI0OsDj9WKCFLTkEuAwg0wg==",
|
"integrity": "sha512-ulSOYcgosx1TqY425cRC5oXtAu1R10+OSmVfgyR9ueR25k4luekURt8dzAZxhxSCI0OsDj9WKCFLTkEuAwg0wg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"iterare": "1.2.1",
|
"iterare": "1.2.1",
|
||||||
"object-hash": "3.0.0",
|
"object-hash": "3.0.0",
|
||||||
@@ -3542,7 +3563,6 @@
|
|||||||
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
|
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "*",
|
"@types/estree": "*",
|
||||||
"@types/json-schema": "*"
|
"@types/json-schema": "*"
|
||||||
@@ -3681,7 +3701,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
|
||||||
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
|
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -3863,7 +3882,6 @@
|
|||||||
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
|
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.53.0",
|
"@typescript-eslint/scope-manager": "8.53.0",
|
||||||
"@typescript-eslint/types": "8.53.0",
|
"@typescript-eslint/types": "8.53.0",
|
||||||
@@ -4545,7 +4563,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -4595,7 +4612,6 @@
|
|||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
"fast-json-stable-stringify": "^2.0.0",
|
"fast-json-stable-stringify": "^2.0.0",
|
||||||
@@ -5038,7 +5054,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -5300,7 +5315,6 @@
|
|||||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"readdirp": "^4.0.1"
|
"readdirp": "^4.0.1"
|
||||||
},
|
},
|
||||||
@@ -5358,15 +5372,13 @@
|
|||||||
"version": "0.5.1",
|
"version": "0.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
|
||||||
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
|
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/class-validator": {
|
"node_modules/class-validator": {
|
||||||
"version": "0.14.3",
|
"version": "0.14.3",
|
||||||
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz",
|
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz",
|
||||||
"integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==",
|
"integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/validator": "^13.15.3",
|
"@types/validator": "^13.15.3",
|
||||||
"libphonenumber-js": "^1.11.1",
|
"libphonenumber-js": "^1.11.1",
|
||||||
@@ -5720,7 +5732,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
@@ -6226,7 +6239,6 @@
|
|||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -6287,7 +6299,6 @@
|
|||||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"eslint-config-prettier": "bin/cli.js"
|
"eslint-config-prettier": "bin/cli.js"
|
||||||
},
|
},
|
||||||
@@ -6520,7 +6531,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
@@ -7271,7 +7281,6 @@
|
|||||||
"integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==",
|
"integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
}
|
}
|
||||||
@@ -7658,7 +7667,6 @@
|
|||||||
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
|
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jest/core": "30.2.0",
|
"@jest/core": "30.2.0",
|
||||||
"@jest/types": "30.2.0",
|
"@jest/types": "30.2.0",
|
||||||
@@ -9457,7 +9465,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
|
||||||
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
|
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"passport-strategy": "1.x.x",
|
"passport-strategy": "1.x.x",
|
||||||
"pause": "0.0.1",
|
"pause": "0.0.1",
|
||||||
@@ -9590,7 +9597,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.17.1.tgz",
|
||||||
"integrity": "sha512-EIR+jXdYNSMOrpRp7g6WgQr7SaZNZfS7IzZIO0oTNEeibq956JxeD15t3Jk3zZH0KH8DmOIx38qJfQenoE8bXQ==",
|
"integrity": "sha512-EIR+jXdYNSMOrpRp7g6WgQr7SaZNZfS7IzZIO0oTNEeibq956JxeD15t3Jk3zZH0KH8DmOIx38qJfQenoE8bXQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.10.0",
|
"pg-connection-string": "^2.10.0",
|
||||||
"pg-pool": "^3.11.0",
|
"pg-pool": "^3.11.0",
|
||||||
@@ -9874,7 +9880,6 @@
|
|||||||
"integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==",
|
"integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -9933,7 +9938,6 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/config": "7.2.0",
|
"@prisma/config": "7.2.0",
|
||||||
"@prisma/dev": "0.17.0",
|
"@prisma/dev": "0.17.0",
|
||||||
@@ -10144,8 +10148,7 @@
|
|||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/regexp-to-ast": {
|
"node_modules/regexp-to-ast": {
|
||||||
"version": "0.5.0",
|
"version": "0.5.0",
|
||||||
@@ -10292,7 +10295,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
@@ -10328,7 +10330,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/schema-utils": {
|
"node_modules/schema-utils": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
@@ -11037,7 +11040,6 @@
|
|||||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
@@ -11381,7 +11383,6 @@
|
|||||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cspotcode/source-map-support": "^0.8.0",
|
"@cspotcode/source-map-support": "^0.8.0",
|
||||||
"@tsconfig/node10": "^1.0.7",
|
"@tsconfig/node10": "^1.0.7",
|
||||||
@@ -11549,7 +11550,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -11831,7 +11831,6 @@
|
|||||||
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
|
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/eslint-scope": "^3.7.7",
|
"@types/eslint-scope": "^3.7.7",
|
||||||
"@types/estree": "^1.0.8",
|
"@types/estree": "^1.0.8",
|
||||||
@@ -11901,7 +11900,6 @@
|
|||||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/platform-socket.io": "^11.1.12",
|
"@nestjs/platform-socket.io": "^11.1.12",
|
||||||
|
"@nestjs/serve-static": "^5.0.4",
|
||||||
"@nestjs/swagger": "^11.2.5",
|
"@nestjs/swagger": "^11.2.5",
|
||||||
"@nestjs/websockets": "^11.1.12",
|
"@nestjs/websockets": "^11.1.12",
|
||||||
"@prisma/adapter-pg": "^7.2.0",
|
"@prisma/adapter-pg": "^7.2.0",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { APP_GUARD } from '@nestjs/core';
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
// Core Modules
|
// Core Modules
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
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 { TranslationsModule } from './modules/translations/translations.module';
|
||||||
import { EquipmentModule } from './modules/equipment/equipment.module';
|
import { EquipmentModule } from './modules/equipment/equipment.module';
|
||||||
import { FeatsModule } from './modules/feats/feats.module';
|
import { FeatsModule } from './modules/feats/feats.module';
|
||||||
|
import { BattleModule } from './modules/battle/battle.module';
|
||||||
|
|
||||||
// Guards
|
// Guards
|
||||||
import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
|
||||||
@@ -26,6 +29,12 @@ import { RolesGuard } from './modules/auth/guards/roles.guard';
|
|||||||
envFilePath: '.env',
|
envFilePath: '.env',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Static file serving for uploads
|
||||||
|
ServeStaticModule.forRoot({
|
||||||
|
rootPath: join(__dirname, '..', 'uploads'),
|
||||||
|
serveRoot: '/uploads',
|
||||||
|
}),
|
||||||
|
|
||||||
// Core
|
// Core
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
ClaudeModule,
|
ClaudeModule,
|
||||||
@@ -37,6 +46,7 @@ import { RolesGuard } from './modules/auth/guards/roles.guard';
|
|||||||
TranslationsModule,
|
TranslationsModule,
|
||||||
EquipmentModule,
|
EquipmentModule,
|
||||||
FeatsModule,
|
FeatsModule,
|
||||||
|
BattleModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global JWT Auth Guard
|
// Global JWT Auth Guard
|
||||||
|
|||||||
113
server/src/modules/battle/battle-maps.controller.ts
Normal file
113
server/src/modules/battle/battle-maps.controller.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseInterceptors,
|
||||||
|
UploadedFile,
|
||||||
|
ParseFilePipe,
|
||||||
|
MaxFileSizeValidator,
|
||||||
|
FileTypeValidator,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiConsumes,
|
||||||
|
ApiBody,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { BattleMapsService } from './battle-maps.service';
|
||||||
|
import { CreateBattleMapDto, UpdateBattleMapDto } from './dto';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
|
||||||
|
@ApiTags('Battle')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('campaigns/:campaignId/battle-maps')
|
||||||
|
export class BattleMapsController {
|
||||||
|
constructor(private readonly battleMapsService: BattleMapsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Get all battle maps for a campaign' })
|
||||||
|
@ApiResponse({ status: 200, description: 'List of battle maps' })
|
||||||
|
async findAll(
|
||||||
|
@Param('campaignId') campaignId: string,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.battleMapsService.findAll(campaignId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':mapId')
|
||||||
|
@ApiOperation({ summary: 'Get a specific battle map' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Battle map details' })
|
||||||
|
async findOne(
|
||||||
|
@Param('campaignId') campaignId: string,
|
||||||
|
@Param('mapId') mapId: string,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.battleMapsService.findOne(campaignId, mapId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseInterceptors(FileInterceptor('image'))
|
||||||
|
@ApiOperation({ summary: 'Upload a new battle map' })
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string' },
|
||||||
|
gridSizeX: { type: 'number' },
|
||||||
|
gridSizeY: { type: 'number' },
|
||||||
|
gridOffsetX: { type: 'number' },
|
||||||
|
gridOffsetY: { type: 'number' },
|
||||||
|
image: { type: 'string', format: 'binary' },
|
||||||
|
},
|
||||||
|
required: ['name', 'image'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 201, description: 'Battle map created' })
|
||||||
|
async create(
|
||||||
|
@Param('campaignId') campaignId: string,
|
||||||
|
@Body() dto: CreateBattleMapDto,
|
||||||
|
@UploadedFile(
|
||||||
|
new ParseFilePipe({
|
||||||
|
validators: [
|
||||||
|
new MaxFileSizeValidator({ maxSize: 10 * 1024 * 1024 }), // 10MB
|
||||||
|
new FileTypeValidator({ fileType: /^image\/(png|jpeg|jpg|webp)$/ }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
image: Express.Multer.File,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.battleMapsService.create(campaignId, dto, image, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':mapId')
|
||||||
|
@ApiOperation({ summary: 'Update battle map settings' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Battle map updated' })
|
||||||
|
async update(
|
||||||
|
@Param('campaignId') campaignId: string,
|
||||||
|
@Param('mapId') mapId: string,
|
||||||
|
@Body() dto: UpdateBattleMapDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.battleMapsService.update(campaignId, mapId, dto, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':mapId')
|
||||||
|
@ApiOperation({ summary: 'Delete a battle map' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Battle map deleted' })
|
||||||
|
async delete(
|
||||||
|
@Param('campaignId') campaignId: string,
|
||||||
|
@Param('mapId') mapId: string,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.battleMapsService.delete(campaignId, mapId, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
170
server/src/modules/battle/battle-maps.service.ts
Normal file
170
server/src/modules/battle/battle-maps.service.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { CreateBattleMapDto, UpdateBattleMapDto } from './dto';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BattleMapsService {
|
||||||
|
private readonly uploadDir = 'uploads/battle-maps';
|
||||||
|
|
||||||
|
constructor(private prisma: PrismaService) {
|
||||||
|
// Ensure upload directory exists
|
||||||
|
this.ensureUploadDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureUploadDir() {
|
||||||
|
const fullPath = path.resolve(this.uploadDir);
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
fs.mkdirSync(fullPath, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(campaignId: string, userId: string) {
|
||||||
|
await this.verifyAccess(campaignId, userId);
|
||||||
|
|
||||||
|
return this.prisma.battleMap.findMany({
|
||||||
|
where: { campaignId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(campaignId: string, mapId: string, userId: string) {
|
||||||
|
await this.verifyAccess(campaignId, userId);
|
||||||
|
|
||||||
|
const map = await this.prisma.battleMap.findFirst({
|
||||||
|
where: { id: mapId, campaignId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!map) {
|
||||||
|
throw new NotFoundException('Battle map not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
campaignId: string,
|
||||||
|
dto: CreateBattleMapDto,
|
||||||
|
imageFile: Express.Multer.File,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
await this.verifyGMAccess(campaignId, userId);
|
||||||
|
|
||||||
|
if (!imageFile) {
|
||||||
|
throw new BadRequestException('Map image is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
const ext = path.extname(imageFile.originalname);
|
||||||
|
const filename = `${Date.now()}-${Math.random().toString(36).substring(7)}${ext}`;
|
||||||
|
const filePath = path.join(this.uploadDir, filename);
|
||||||
|
|
||||||
|
// Save file
|
||||||
|
fs.writeFileSync(filePath, imageFile.buffer);
|
||||||
|
|
||||||
|
// Create relative URL for serving
|
||||||
|
const imageUrl = `/uploads/battle-maps/${filename}`;
|
||||||
|
|
||||||
|
return this.prisma.battleMap.create({
|
||||||
|
data: {
|
||||||
|
campaignId,
|
||||||
|
name: dto.name,
|
||||||
|
imageUrl,
|
||||||
|
gridSizeX: dto.gridSizeX ?? 20,
|
||||||
|
gridSizeY: dto.gridSizeY ?? 20,
|
||||||
|
gridOffsetX: dto.gridOffsetX ?? 0,
|
||||||
|
gridOffsetY: dto.gridOffsetY ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(campaignId: string, mapId: string, dto: UpdateBattleMapDto, userId: string) {
|
||||||
|
await this.verifyGMAccess(campaignId, userId);
|
||||||
|
|
||||||
|
const map = await this.prisma.battleMap.findFirst({
|
||||||
|
where: { id: mapId, campaignId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!map) {
|
||||||
|
throw new NotFoundException('Battle map not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.battleMap.update({
|
||||||
|
where: { id: mapId },
|
||||||
|
data: {
|
||||||
|
name: dto.name,
|
||||||
|
gridSizeX: dto.gridSizeX,
|
||||||
|
gridSizeY: dto.gridSizeY,
|
||||||
|
gridOffsetX: dto.gridOffsetX,
|
||||||
|
gridOffsetY: dto.gridOffsetY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(campaignId: string, mapId: string, userId: string) {
|
||||||
|
await this.verifyGMAccess(campaignId, userId);
|
||||||
|
|
||||||
|
const map = await this.prisma.battleMap.findFirst({
|
||||||
|
where: { id: mapId, campaignId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!map) {
|
||||||
|
throw new NotFoundException('Battle map not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete file from disk
|
||||||
|
if (map.imageUrl) {
|
||||||
|
const filePath = path.join(process.cwd(), map.imageUrl);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.battleMap.delete({
|
||||||
|
where: { id: mapId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'Battle map deleted' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// ACCESS HELPERS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
private async verifyAccess(campaignId: string, userId: string) {
|
||||||
|
const campaign = await this.prisma.campaign.findUnique({
|
||||||
|
where: { id: campaignId },
|
||||||
|
include: { members: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!campaign) {
|
||||||
|
throw new NotFoundException('Campaign not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGM = campaign.gmId === userId;
|
||||||
|
const isMember = campaign.members.some(m => m.userId === userId);
|
||||||
|
|
||||||
|
if (!isGM && !isMember) {
|
||||||
|
throw new ForbiddenException('No access to this campaign');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { campaign, isGM };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verifyGMAccess(campaignId: string, userId: string) {
|
||||||
|
const campaign = await this.prisma.campaign.findUnique({
|
||||||
|
where: { id: campaignId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!campaign) {
|
||||||
|
throw new NotFoundException('Campaign not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (campaign.gmId !== userId) {
|
||||||
|
throw new ForbiddenException('Only the GM can perform this action');
|
||||||
|
}
|
||||||
|
|
||||||
|
return campaign;
|
||||||
|
}
|
||||||
|
}
|
||||||
167
server/src/modules/battle/battle.controller.ts
Normal file
167
server/src/modules/battle/battle.controller.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { BattleService } from './battle.service';
|
||||||
|
import { CreateBattleSessionDto, UpdateBattleSessionDto, CreateBattleTokenDto, UpdateBattleTokenDto } from './dto';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
|
||||||
|
@ApiTags('Battle')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('campaigns/:campaignId/battles')
|
||||||
|
export class BattleController {
|
||||||
|
constructor(private readonly battleService: BattleService) {}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// BATTLE SESSIONS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Get all battle sessions for a campaign' })
|
||||||
|
@ApiResponse({ status: 200, description: 'List of battle sessions' })
|
||||||
|
async findAllSessions(
|
||||||
|
@Param('campaignId') campaignId: string,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.battleService.findAllSessions(campaignId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':sessionId')
|
||||||
|
@ApiOperation({ summary: 'Get a specific battle session' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Battle session details' })
|
||||||
|
async findSession(
|
||||||
|
@Param('campaignId') campaignId: string,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.battleService.findSession(campaignId, sessionId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Create a new battle session' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Battle session created' })
|
||||||
|
async createSession(
|
||||||
|
@Param('campaignId') campaignId: string,
|
||||||
|
@Body() dto: CreateBattleSessionDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.battleService.createSession(campaignId, dto, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':sessionId')
|
||||||
|
@ApiOperation({ summary: 'Update a battle session' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Battle session updated' })
|
||||||
|
async updateSession(
|
||||||
|
@Param('campaignId') campaignId: string,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
@Body() dto: UpdateBattleSessionDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.battleService.updateSession(campaignId, sessionId, dto, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':sessionId')
|
||||||
|
@ApiOperation({ summary: 'Delete a battle session' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Battle session deleted' })
|
||||||
|
async deleteSession(
|
||||||
|
@Param('campaignId') campaignId: string,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.battleService.deleteSession(campaignId, sessionId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// BATTLE TOKENS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
@Post(':sessionId/tokens')
|
||||||
|
@ApiOperation({ summary: 'Add a token to the battle' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Token added' })
|
||||||
|
async addToken(
|
||||||
|
@Param('campaignId') campaignId: string,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
@Body() dto: CreateBattleTokenDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.battleService.addToken(campaignId, sessionId, dto, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':sessionId/tokens/:tokenId')
|
||||||
|
@ApiOperation({ summary: 'Update a token' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Token updated' })
|
||||||
|
async updateToken(
|
||||||
|
@Param('campaignId') campaignId: string,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
@Param('tokenId') tokenId: string,
|
||||||
|
@Body() dto: UpdateBattleTokenDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.battleService.updateToken(campaignId, sessionId, tokenId, dto, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':sessionId/tokens/:tokenId')
|
||||||
|
@ApiOperation({ summary: 'Remove a token from the battle' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Token removed' })
|
||||||
|
async removeToken(
|
||||||
|
@Param('campaignId') campaignId: string,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
@Param('tokenId') tokenId: string,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.battleService.removeToken(campaignId, sessionId, tokenId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':sessionId/tokens/:tokenId/move')
|
||||||
|
@ApiOperation({ summary: 'Move a token on the map' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Token moved' })
|
||||||
|
async moveToken(
|
||||||
|
@Param('campaignId') campaignId: string,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
@Param('tokenId') tokenId: string,
|
||||||
|
@Body() body: { positionX: number; positionY: number },
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.battleService.moveToken(campaignId, sessionId, tokenId, body.positionX, body.positionY, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// INITIATIVE
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
@Patch(':sessionId/tokens/:tokenId/initiative')
|
||||||
|
@ApiOperation({ summary: 'Set token initiative' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Initiative set' })
|
||||||
|
async setInitiative(
|
||||||
|
@Param('campaignId') campaignId: string,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
@Param('tokenId') tokenId: string,
|
||||||
|
@Body() body: { initiative: number },
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.battleService.setInitiative(campaignId, sessionId, tokenId, body.initiative, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':sessionId/advance-round')
|
||||||
|
@ApiOperation({ summary: 'Advance to the next round' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Round advanced' })
|
||||||
|
async advanceRound(
|
||||||
|
@Param('campaignId') campaignId: string,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.battleService.advanceRound(campaignId, sessionId, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
375
server/src/modules/battle/battle.gateway.ts
Normal file
375
server/src/modules/battle/battle.gateway.ts
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
import {
|
||||||
|
WebSocketGateway,
|
||||||
|
WebSocketServer,
|
||||||
|
SubscribeMessage,
|
||||||
|
OnGatewayConnection,
|
||||||
|
OnGatewayDisconnect,
|
||||||
|
ConnectedSocket,
|
||||||
|
MessageBody,
|
||||||
|
} from '@nestjs/websockets';
|
||||||
|
import { Server, Socket } from 'socket.io';
|
||||||
|
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { BattleService } from './battle.service';
|
||||||
|
|
||||||
|
interface AuthenticatedSocket extends Socket {
|
||||||
|
userId?: string;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BattleUpdateType =
|
||||||
|
| 'token_added'
|
||||||
|
| 'token_removed'
|
||||||
|
| 'token_moved'
|
||||||
|
| 'token_updated'
|
||||||
|
| 'token_hp_changed'
|
||||||
|
| 'initiative_set'
|
||||||
|
| 'round_advanced'
|
||||||
|
| 'session_updated'
|
||||||
|
| 'session_deleted';
|
||||||
|
|
||||||
|
export interface BattleUpdatePayload {
|
||||||
|
sessionId: string;
|
||||||
|
type: BattleUpdateType;
|
||||||
|
data: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORS origins from environment (fallback to common dev ports)
|
||||||
|
const getCorsOrigins = () => {
|
||||||
|
const origins = process.env.CORS_ORIGINS;
|
||||||
|
if (origins) {
|
||||||
|
return origins.split(',').map(o => o.trim());
|
||||||
|
}
|
||||||
|
return ['http://localhost:3000', 'http://localhost:5173', 'http://localhost:5175'];
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
@WebSocketGateway({
|
||||||
|
cors: {
|
||||||
|
origin: getCorsOrigins(),
|
||||||
|
credentials: true,
|
||||||
|
},
|
||||||
|
namespace: '/battles',
|
||||||
|
})
|
||||||
|
export class BattleGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||||
|
@WebSocketServer()
|
||||||
|
server: Server;
|
||||||
|
|
||||||
|
private logger = new Logger('BattleGateway');
|
||||||
|
private connectedClients = new Map<string, Set<string>>(); // sessionId -> Set<socketId>
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private jwtService: JwtService,
|
||||||
|
private configService: ConfigService,
|
||||||
|
private prisma: PrismaService,
|
||||||
|
@Inject(forwardRef(() => BattleService))
|
||||||
|
private battleService: BattleService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handleConnection(client: AuthenticatedSocket) {
|
||||||
|
try {
|
||||||
|
const token = client.handshake.auth.token || client.handshake.headers.authorization?.split(' ')[1];
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
this.logger.warn(`Client ${client.id} disconnected: No token provided`);
|
||||||
|
client.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = this.configService.get<string>('JWT_SECRET');
|
||||||
|
const payload = this.jwtService.verify(token, { secret });
|
||||||
|
|
||||||
|
// Verify user exists
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: payload.sub },
|
||||||
|
select: { id: true, username: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
this.logger.warn(`Client ${client.id} disconnected: User not found`);
|
||||||
|
client.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.userId = user.id;
|
||||||
|
client.username = user.username;
|
||||||
|
|
||||||
|
this.logger.log(`Battle client connected: ${client.id} (User: ${user.username})`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Client ${client.id} disconnected: Invalid token`);
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDisconnect(client: AuthenticatedSocket) {
|
||||||
|
// Remove client from all battle rooms
|
||||||
|
this.connectedClients.forEach((clients, sessionId) => {
|
||||||
|
clients.delete(client.id);
|
||||||
|
if (clients.size === 0) {
|
||||||
|
this.connectedClients.delete(sessionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Battle client disconnected: ${client.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('join_battle')
|
||||||
|
async handleJoinBattle(
|
||||||
|
@ConnectedSocket() client: AuthenticatedSocket,
|
||||||
|
@MessageBody() data: { sessionId: string },
|
||||||
|
) {
|
||||||
|
if (!client.userId) {
|
||||||
|
return { success: false, error: 'Not authenticated' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify user has access to this battle session
|
||||||
|
const session = await this.prisma.battleSession.findUnique({
|
||||||
|
where: { id: data.sessionId },
|
||||||
|
include: {
|
||||||
|
campaign: {
|
||||||
|
include: { members: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return { success: false, error: 'Battle session not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGM = session.campaign.gmId === client.userId;
|
||||||
|
const isMember = session.campaign.members.some(m => m.userId === client.userId);
|
||||||
|
|
||||||
|
if (!isGM && !isMember) {
|
||||||
|
return { success: false, error: 'No access to this battle' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join the room
|
||||||
|
const room = `battle:${data.sessionId}`;
|
||||||
|
client.join(room);
|
||||||
|
|
||||||
|
// Track connected clients
|
||||||
|
if (!this.connectedClients.has(data.sessionId)) {
|
||||||
|
this.connectedClients.set(data.sessionId, new Set());
|
||||||
|
}
|
||||||
|
this.connectedClients.get(data.sessionId)?.add(client.id);
|
||||||
|
|
||||||
|
this.logger.log(`Client ${client.id} joined battle room: ${data.sessionId} (isGM: ${isGM})`);
|
||||||
|
|
||||||
|
return { success: true, isGM };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error joining battle room: ${error}`);
|
||||||
|
return { success: false, error: 'Failed to join battle room' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('leave_battle')
|
||||||
|
handleLeaveBattle(
|
||||||
|
@ConnectedSocket() client: AuthenticatedSocket,
|
||||||
|
@MessageBody() data: { sessionId: string },
|
||||||
|
) {
|
||||||
|
const room = `battle:${data.sessionId}`;
|
||||||
|
client.leave(room);
|
||||||
|
|
||||||
|
this.connectedClients.get(data.sessionId)?.delete(client.id);
|
||||||
|
if (this.connectedClients.get(data.sessionId)?.size === 0) {
|
||||||
|
this.connectedClients.delete(data.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Client ${client.id} left battle room: ${data.sessionId}`);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('move_token')
|
||||||
|
async handleMoveToken(
|
||||||
|
@ConnectedSocket() client: AuthenticatedSocket,
|
||||||
|
@MessageBody() data: { sessionId: string; tokenId: string; positionX: number; positionY: number },
|
||||||
|
) {
|
||||||
|
if (!client.userId) {
|
||||||
|
return { success: false, error: 'Not authenticated' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get session to verify campaign
|
||||||
|
const session = await this.prisma.battleSession.findUnique({
|
||||||
|
where: { id: data.sessionId },
|
||||||
|
select: { campaignId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return { success: false, error: 'Session not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move the token via service (this validates GM access)
|
||||||
|
const token = await this.battleService.moveToken(
|
||||||
|
session.campaignId,
|
||||||
|
data.sessionId,
|
||||||
|
data.tokenId,
|
||||||
|
data.positionX,
|
||||||
|
data.positionY,
|
||||||
|
client.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Broadcast to all clients in the room
|
||||||
|
this.broadcastBattleUpdate(data.sessionId, {
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
type: 'token_moved',
|
||||||
|
data: {
|
||||||
|
tokenId: data.tokenId,
|
||||||
|
positionX: data.positionX,
|
||||||
|
positionY: data.positionY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, token };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error moving token: ${error}`);
|
||||||
|
return { success: false, error: error instanceof Error ? error.message : 'Failed to move token' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('update_token_hp')
|
||||||
|
async handleUpdateTokenHp(
|
||||||
|
@ConnectedSocket() client: AuthenticatedSocket,
|
||||||
|
@MessageBody() data: { sessionId: string; tokenId: string; hpCurrent: number },
|
||||||
|
) {
|
||||||
|
if (!client.userId) {
|
||||||
|
return { success: false, error: 'Not authenticated' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await this.prisma.battleSession.findUnique({
|
||||||
|
where: { id: data.sessionId },
|
||||||
|
select: { campaignId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return { success: false, error: 'Session not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await this.battleService.updateToken(
|
||||||
|
session.campaignId,
|
||||||
|
data.sessionId,
|
||||||
|
data.tokenId,
|
||||||
|
{ hpCurrent: data.hpCurrent },
|
||||||
|
client.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Broadcast to all clients
|
||||||
|
this.broadcastBattleUpdate(data.sessionId, {
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
type: 'token_hp_changed',
|
||||||
|
data: {
|
||||||
|
tokenId: data.tokenId,
|
||||||
|
hpCurrent: data.hpCurrent,
|
||||||
|
hpMax: token.hpMax,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, token };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error updating token HP: ${error}`);
|
||||||
|
return { success: false, error: error instanceof Error ? error.message : 'Failed to update HP' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('set_initiative')
|
||||||
|
async handleSetInitiative(
|
||||||
|
@ConnectedSocket() client: AuthenticatedSocket,
|
||||||
|
@MessageBody() data: { sessionId: string; tokenId: string; initiative: number },
|
||||||
|
) {
|
||||||
|
if (!client.userId) {
|
||||||
|
return { success: false, error: 'Not authenticated' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await this.prisma.battleSession.findUnique({
|
||||||
|
where: { id: data.sessionId },
|
||||||
|
select: { campaignId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return { success: false, error: 'Session not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await this.battleService.setInitiative(
|
||||||
|
session.campaignId,
|
||||||
|
data.sessionId,
|
||||||
|
data.tokenId,
|
||||||
|
data.initiative,
|
||||||
|
client.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Broadcast to all clients
|
||||||
|
this.broadcastBattleUpdate(data.sessionId, {
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
type: 'initiative_set',
|
||||||
|
data: {
|
||||||
|
tokenId: data.tokenId,
|
||||||
|
initiative: data.initiative,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, token };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error setting initiative: ${error}`);
|
||||||
|
return { success: false, error: error instanceof Error ? error.message : 'Failed to set initiative' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('advance_round')
|
||||||
|
async handleAdvanceRound(
|
||||||
|
@ConnectedSocket() client: AuthenticatedSocket,
|
||||||
|
@MessageBody() data: { sessionId: string },
|
||||||
|
) {
|
||||||
|
if (!client.userId) {
|
||||||
|
return { success: false, error: 'Not authenticated' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await this.prisma.battleSession.findUnique({
|
||||||
|
where: { id: data.sessionId },
|
||||||
|
select: { campaignId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return { success: false, error: 'Session not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedSession = await this.battleService.advanceRound(
|
||||||
|
session.campaignId,
|
||||||
|
data.sessionId,
|
||||||
|
client.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Broadcast to all clients
|
||||||
|
this.broadcastBattleUpdate(data.sessionId, {
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
type: 'round_advanced',
|
||||||
|
data: {
|
||||||
|
roundNumber: updatedSession.roundNumber,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, session: updatedSession };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error advancing round: ${error}`);
|
||||||
|
return { success: false, error: error instanceof Error ? error.message : 'Failed to advance round' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast battle update to all clients in the room
|
||||||
|
broadcastBattleUpdate(sessionId: string, update: BattleUpdatePayload) {
|
||||||
|
const room = `battle:${sessionId}`;
|
||||||
|
this.server.to(room).emit('battle_update', update);
|
||||||
|
this.logger.debug(`Broadcast to ${room}: ${update.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get number of connected clients for a battle session
|
||||||
|
getConnectedClientsCount(sessionId: string): number {
|
||||||
|
return this.connectedClients.get(sessionId)?.size || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
51
server/src/modules/battle/battle.module.ts
Normal file
51
server/src/modules/battle/battle.module.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { MulterModule } from '@nestjs/platform-express';
|
||||||
|
import { memoryStorage } from 'multer';
|
||||||
|
|
||||||
|
// Controllers
|
||||||
|
import { BattleController } from './battle.controller';
|
||||||
|
import { BattleMapsController } from './battle-maps.controller';
|
||||||
|
import { CombatantsController } from './combatants.controller';
|
||||||
|
|
||||||
|
// Services
|
||||||
|
import { BattleService } from './battle.service';
|
||||||
|
import { BattleMapsService } from './battle-maps.service';
|
||||||
|
import { CombatantsService } from './combatants.service';
|
||||||
|
|
||||||
|
// Gateway
|
||||||
|
import { BattleGateway } from './battle.gateway';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: async (configService: ConfigService) => ({
|
||||||
|
secret: configService.get<string>('JWT_SECRET'),
|
||||||
|
}),
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
MulterModule.register({
|
||||||
|
storage: memoryStorage(),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [
|
||||||
|
BattleController,
|
||||||
|
BattleMapsController,
|
||||||
|
CombatantsController,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
BattleService,
|
||||||
|
BattleMapsService,
|
||||||
|
CombatantsService,
|
||||||
|
BattleGateway,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
BattleService,
|
||||||
|
BattleMapsService,
|
||||||
|
CombatantsService,
|
||||||
|
BattleGateway,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class BattleModule {}
|
||||||
377
server/src/modules/battle/battle.service.ts
Normal file
377
server/src/modules/battle/battle.service.ts
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { CreateBattleSessionDto, UpdateBattleSessionDto, CreateBattleTokenDto, UpdateBattleTokenDto } from './dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BattleService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// BATTLE SESSIONS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
async findAllSessions(campaignId: string, userId: string) {
|
||||||
|
await this.verifyAccess(campaignId, userId);
|
||||||
|
|
||||||
|
return this.prisma.battleSession.findMany({
|
||||||
|
where: { campaignId },
|
||||||
|
include: {
|
||||||
|
map: true,
|
||||||
|
tokens: {
|
||||||
|
include: {
|
||||||
|
combatant: true,
|
||||||
|
character: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findSession(campaignId: string, sessionId: string, userId: string) {
|
||||||
|
await this.verifyAccess(campaignId, userId);
|
||||||
|
|
||||||
|
const session = await this.prisma.battleSession.findFirst({
|
||||||
|
where: { id: sessionId, campaignId },
|
||||||
|
include: {
|
||||||
|
map: true,
|
||||||
|
tokens: {
|
||||||
|
include: {
|
||||||
|
combatant: {
|
||||||
|
include: { abilities: true },
|
||||||
|
},
|
||||||
|
character: {
|
||||||
|
include: {
|
||||||
|
abilities: true,
|
||||||
|
conditions: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ initiative: 'desc' },
|
||||||
|
{ name: 'asc' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new NotFoundException('Battle session not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(campaignId: string, dto: CreateBattleSessionDto, userId: string) {
|
||||||
|
await this.verifyGMAccess(campaignId, userId);
|
||||||
|
|
||||||
|
// Verify map exists if provided
|
||||||
|
if (dto.mapId) {
|
||||||
|
const map = await this.prisma.battleMap.findFirst({
|
||||||
|
where: { id: dto.mapId, campaignId },
|
||||||
|
});
|
||||||
|
if (!map) {
|
||||||
|
throw new NotFoundException('Battle map not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.battleSession.create({
|
||||||
|
data: {
|
||||||
|
campaignId,
|
||||||
|
name: dto.name,
|
||||||
|
mapId: dto.mapId,
|
||||||
|
isActive: dto.isActive ?? false,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
map: true,
|
||||||
|
tokens: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSession(campaignId: string, sessionId: string, dto: UpdateBattleSessionDto, userId: string) {
|
||||||
|
await this.verifyGMAccess(campaignId, userId);
|
||||||
|
|
||||||
|
const session = await this.prisma.battleSession.findFirst({
|
||||||
|
where: { id: sessionId, campaignId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new NotFoundException('Battle session not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify map exists if changing
|
||||||
|
if (dto.mapId) {
|
||||||
|
const map = await this.prisma.battleMap.findFirst({
|
||||||
|
where: { id: dto.mapId, campaignId },
|
||||||
|
});
|
||||||
|
if (!map) {
|
||||||
|
throw new NotFoundException('Battle map not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.battleSession.update({
|
||||||
|
where: { id: sessionId },
|
||||||
|
data: {
|
||||||
|
name: dto.name,
|
||||||
|
mapId: dto.mapId,
|
||||||
|
isActive: dto.isActive,
|
||||||
|
roundNumber: dto.roundNumber,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
map: true,
|
||||||
|
tokens: {
|
||||||
|
include: {
|
||||||
|
combatant: true,
|
||||||
|
character: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSession(campaignId: string, sessionId: string, userId: string) {
|
||||||
|
await this.verifyGMAccess(campaignId, userId);
|
||||||
|
|
||||||
|
const session = await this.prisma.battleSession.findFirst({
|
||||||
|
where: { id: sessionId, campaignId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new NotFoundException('Battle session not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.battleSession.delete({
|
||||||
|
where: { id: sessionId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'Battle session deleted' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// BATTLE TOKENS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
async addToken(campaignId: string, sessionId: string, dto: CreateBattleTokenDto, userId: string) {
|
||||||
|
await this.verifyGMAccess(campaignId, userId);
|
||||||
|
|
||||||
|
const session = await this.prisma.battleSession.findFirst({
|
||||||
|
where: { id: sessionId, campaignId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new NotFoundException('Battle session not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify combatant or character exists if provided
|
||||||
|
if (dto.combatantId) {
|
||||||
|
const combatant = await this.prisma.combatant.findFirst({
|
||||||
|
where: { id: dto.combatantId, campaignId },
|
||||||
|
});
|
||||||
|
if (!combatant) {
|
||||||
|
throw new NotFoundException('Combatant not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.characterId) {
|
||||||
|
const character = await this.prisma.character.findFirst({
|
||||||
|
where: { id: dto.characterId, campaignId },
|
||||||
|
});
|
||||||
|
if (!character) {
|
||||||
|
throw new NotFoundException('Character not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.battleToken.create({
|
||||||
|
data: {
|
||||||
|
battleSessionId: sessionId,
|
||||||
|
combatantId: dto.combatantId,
|
||||||
|
characterId: dto.characterId,
|
||||||
|
name: dto.name,
|
||||||
|
positionX: dto.positionX,
|
||||||
|
positionY: dto.positionY,
|
||||||
|
hpCurrent: dto.hpCurrent,
|
||||||
|
hpMax: dto.hpMax,
|
||||||
|
initiative: dto.initiative,
|
||||||
|
conditions: dto.conditions ?? [],
|
||||||
|
size: dto.size ?? 1,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
combatant: {
|
||||||
|
include: { abilities: true },
|
||||||
|
},
|
||||||
|
character: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateToken(campaignId: string, sessionId: string, tokenId: string, dto: UpdateBattleTokenDto, userId: string) {
|
||||||
|
await this.verifyGMAccess(campaignId, userId);
|
||||||
|
|
||||||
|
const token = await this.prisma.battleToken.findFirst({
|
||||||
|
where: { id: tokenId, battleSessionId: sessionId },
|
||||||
|
include: { battleSession: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!token || token.battleSession.campaignId !== campaignId) {
|
||||||
|
throw new NotFoundException('Battle token not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.battleToken.update({
|
||||||
|
where: { id: tokenId },
|
||||||
|
data: {
|
||||||
|
name: dto.name,
|
||||||
|
positionX: dto.positionX,
|
||||||
|
positionY: dto.positionY,
|
||||||
|
hpCurrent: dto.hpCurrent,
|
||||||
|
hpMax: dto.hpMax,
|
||||||
|
initiative: dto.initiative,
|
||||||
|
conditions: dto.conditions,
|
||||||
|
size: dto.size,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
combatant: {
|
||||||
|
include: { abilities: true },
|
||||||
|
},
|
||||||
|
character: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeToken(campaignId: string, sessionId: string, tokenId: string, userId: string) {
|
||||||
|
await this.verifyGMAccess(campaignId, userId);
|
||||||
|
|
||||||
|
const token = await this.prisma.battleToken.findFirst({
|
||||||
|
where: { id: tokenId, battleSessionId: sessionId },
|
||||||
|
include: { battleSession: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!token || token.battleSession.campaignId !== campaignId) {
|
||||||
|
throw new NotFoundException('Battle token not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.battleToken.delete({
|
||||||
|
where: { id: tokenId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'Token removed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveToken(campaignId: string, sessionId: string, tokenId: string, positionX: number, positionY: number, userId: string) {
|
||||||
|
await this.verifyGMAccess(campaignId, userId);
|
||||||
|
|
||||||
|
const token = await this.prisma.battleToken.findFirst({
|
||||||
|
where: { id: tokenId, battleSessionId: sessionId },
|
||||||
|
include: { battleSession: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!token || token.battleSession.campaignId !== campaignId) {
|
||||||
|
throw new NotFoundException('Battle token not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.battleToken.update({
|
||||||
|
where: { id: tokenId },
|
||||||
|
data: { positionX, positionY },
|
||||||
|
include: {
|
||||||
|
combatant: true,
|
||||||
|
character: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// INITIATIVE
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
async setInitiative(campaignId: string, sessionId: string, tokenId: string, initiative: number, userId: string) {
|
||||||
|
await this.verifyGMAccess(campaignId, userId);
|
||||||
|
|
||||||
|
const token = await this.prisma.battleToken.findFirst({
|
||||||
|
where: { id: tokenId, battleSessionId: sessionId },
|
||||||
|
include: { battleSession: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!token || token.battleSession.campaignId !== campaignId) {
|
||||||
|
throw new NotFoundException('Battle token not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.battleToken.update({
|
||||||
|
where: { id: tokenId },
|
||||||
|
data: { initiative },
|
||||||
|
include: {
|
||||||
|
combatant: true,
|
||||||
|
character: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async advanceRound(campaignId: string, sessionId: string, userId: string) {
|
||||||
|
await this.verifyGMAccess(campaignId, userId);
|
||||||
|
|
||||||
|
const session = await this.prisma.battleSession.findFirst({
|
||||||
|
where: { id: sessionId, campaignId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new NotFoundException('Battle session not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.battleSession.update({
|
||||||
|
where: { id: sessionId },
|
||||||
|
data: { roundNumber: session.roundNumber + 1 },
|
||||||
|
include: {
|
||||||
|
map: true,
|
||||||
|
tokens: {
|
||||||
|
include: {
|
||||||
|
combatant: true,
|
||||||
|
character: true,
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ initiative: 'desc' },
|
||||||
|
{ name: 'asc' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// ACCESS HELPERS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
private async verifyAccess(campaignId: string, userId: string) {
|
||||||
|
const campaign = await this.prisma.campaign.findUnique({
|
||||||
|
where: { id: campaignId },
|
||||||
|
include: { members: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!campaign) {
|
||||||
|
throw new NotFoundException('Campaign not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGM = campaign.gmId === userId;
|
||||||
|
const isMember = campaign.members.some(m => m.userId === userId);
|
||||||
|
|
||||||
|
if (!isGM && !isMember) {
|
||||||
|
throw new ForbiddenException('No access to this campaign');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { campaign, isGM };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verifyGMAccess(campaignId: string, userId: string) {
|
||||||
|
const campaign = await this.prisma.campaign.findUnique({
|
||||||
|
where: { id: campaignId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!campaign) {
|
||||||
|
throw new NotFoundException('Campaign not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (campaign.gmId !== userId) {
|
||||||
|
throw new ForbiddenException('Only the GM can perform this action');
|
||||||
|
}
|
||||||
|
|
||||||
|
return campaign;
|
||||||
|
}
|
||||||
|
}
|
||||||
80
server/src/modules/battle/combatants.controller.ts
Normal file
80
server/src/modules/battle/combatants.controller.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { CombatantsService } from './combatants.service';
|
||||||
|
import { CreateCombatantDto } from './dto';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
|
||||||
|
@ApiTags('Battle')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('campaigns/:campaignId/combatants')
|
||||||
|
export class CombatantsController {
|
||||||
|
constructor(private readonly combatantsService: CombatantsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Get all NPC/Monster templates for a campaign' })
|
||||||
|
@ApiResponse({ status: 200, description: 'List of combatants' })
|
||||||
|
async findAll(
|
||||||
|
@Param('campaignId') campaignId: string,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.combatantsService.findAll(campaignId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':combatantId')
|
||||||
|
@ApiOperation({ summary: 'Get a specific combatant template' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Combatant details' })
|
||||||
|
async findOne(
|
||||||
|
@Param('campaignId') campaignId: string,
|
||||||
|
@Param('combatantId') combatantId: string,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.combatantsService.findOne(campaignId, combatantId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Create a new NPC/Monster template' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Combatant created' })
|
||||||
|
async create(
|
||||||
|
@Param('campaignId') campaignId: string,
|
||||||
|
@Body() dto: CreateCombatantDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.combatantsService.create(campaignId, dto, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':combatantId')
|
||||||
|
@ApiOperation({ summary: 'Update a combatant template' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Combatant updated' })
|
||||||
|
async update(
|
||||||
|
@Param('campaignId') campaignId: string,
|
||||||
|
@Param('combatantId') combatantId: string,
|
||||||
|
@Body() dto: CreateCombatantDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.combatantsService.update(campaignId, combatantId, dto, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':combatantId')
|
||||||
|
@ApiOperation({ summary: 'Delete a combatant template' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Combatant deleted' })
|
||||||
|
async delete(
|
||||||
|
@Param('campaignId') campaignId: string,
|
||||||
|
@Param('combatantId') combatantId: string,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.combatantsService.delete(campaignId, combatantId, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
178
server/src/modules/battle/combatants.service.ts
Normal file
178
server/src/modules/battle/combatants.service.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { CreateCombatantDto } from './dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CombatantsService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findAll(campaignId: string, userId: string) {
|
||||||
|
await this.verifyAccess(campaignId, userId);
|
||||||
|
|
||||||
|
return this.prisma.combatant.findMany({
|
||||||
|
where: { campaignId },
|
||||||
|
include: { abilities: true },
|
||||||
|
orderBy: [
|
||||||
|
{ level: 'asc' },
|
||||||
|
{ name: 'asc' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(campaignId: string, combatantId: string, userId: string) {
|
||||||
|
await this.verifyAccess(campaignId, userId);
|
||||||
|
|
||||||
|
const combatant = await this.prisma.combatant.findFirst({
|
||||||
|
where: { id: combatantId, campaignId },
|
||||||
|
include: { abilities: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!combatant) {
|
||||||
|
throw new NotFoundException('Combatant not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return combatant;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(campaignId: string, dto: CreateCombatantDto, userId: string) {
|
||||||
|
await this.verifyGMAccess(campaignId, userId);
|
||||||
|
|
||||||
|
return this.prisma.combatant.create({
|
||||||
|
data: {
|
||||||
|
campaignId,
|
||||||
|
name: dto.name,
|
||||||
|
type: dto.type,
|
||||||
|
level: dto.level,
|
||||||
|
hpMax: dto.hpMax,
|
||||||
|
ac: dto.ac,
|
||||||
|
fortitude: dto.fortitude,
|
||||||
|
reflex: dto.reflex,
|
||||||
|
will: dto.will,
|
||||||
|
perception: dto.perception,
|
||||||
|
speed: dto.speed ?? 25,
|
||||||
|
avatarUrl: dto.avatarUrl,
|
||||||
|
description: dto.description,
|
||||||
|
abilities: {
|
||||||
|
create: dto.abilities?.map(ability => ({
|
||||||
|
name: ability.name,
|
||||||
|
actionCost: ability.actionCost,
|
||||||
|
actionType: ability.actionType,
|
||||||
|
description: ability.description,
|
||||||
|
damage: ability.damage,
|
||||||
|
traits: ability.traits ?? [],
|
||||||
|
})) ?? [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { abilities: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(campaignId: string, combatantId: string, dto: Partial<CreateCombatantDto>, userId: string) {
|
||||||
|
await this.verifyGMAccess(campaignId, userId);
|
||||||
|
|
||||||
|
const combatant = await this.prisma.combatant.findFirst({
|
||||||
|
where: { id: combatantId, campaignId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!combatant) {
|
||||||
|
throw new NotFoundException('Combatant not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update combatant and replace abilities if provided
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
// Delete existing abilities if new ones are provided
|
||||||
|
if (dto.abilities !== undefined) {
|
||||||
|
await tx.combatantAbility.deleteMany({
|
||||||
|
where: { combatantId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.combatant.update({
|
||||||
|
where: { id: combatantId },
|
||||||
|
data: {
|
||||||
|
name: dto.name,
|
||||||
|
type: dto.type,
|
||||||
|
level: dto.level,
|
||||||
|
hpMax: dto.hpMax,
|
||||||
|
ac: dto.ac,
|
||||||
|
fortitude: dto.fortitude,
|
||||||
|
reflex: dto.reflex,
|
||||||
|
will: dto.will,
|
||||||
|
perception: dto.perception,
|
||||||
|
speed: dto.speed,
|
||||||
|
avatarUrl: dto.avatarUrl,
|
||||||
|
description: dto.description,
|
||||||
|
abilities: dto.abilities ? {
|
||||||
|
create: dto.abilities.map(ability => ({
|
||||||
|
name: ability.name,
|
||||||
|
actionCost: ability.actionCost,
|
||||||
|
actionType: ability.actionType,
|
||||||
|
description: ability.description,
|
||||||
|
damage: ability.damage,
|
||||||
|
traits: ability.traits ?? [],
|
||||||
|
})),
|
||||||
|
} : undefined,
|
||||||
|
},
|
||||||
|
include: { abilities: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(campaignId: string, combatantId: string, userId: string) {
|
||||||
|
await this.verifyGMAccess(campaignId, userId);
|
||||||
|
|
||||||
|
const combatant = await this.prisma.combatant.findFirst({
|
||||||
|
where: { id: combatantId, campaignId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!combatant) {
|
||||||
|
throw new NotFoundException('Combatant not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.combatant.delete({
|
||||||
|
where: { id: combatantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'Combatant deleted' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// ACCESS HELPERS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
private async verifyAccess(campaignId: string, userId: string) {
|
||||||
|
const campaign = await this.prisma.campaign.findUnique({
|
||||||
|
where: { id: campaignId },
|
||||||
|
include: { members: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!campaign) {
|
||||||
|
throw new NotFoundException('Campaign not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGM = campaign.gmId === userId;
|
||||||
|
const isMember = campaign.members.some(m => m.userId === userId);
|
||||||
|
|
||||||
|
if (!isGM && !isMember) {
|
||||||
|
throw new ForbiddenException('No access to this campaign');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { campaign, isGM };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verifyGMAccess(campaignId: string, userId: string) {
|
||||||
|
const campaign = await this.prisma.campaign.findUnique({
|
||||||
|
where: { id: campaignId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!campaign) {
|
||||||
|
throw new NotFoundException('Campaign not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (campaign.gmId !== userId) {
|
||||||
|
throw new ForbiddenException('Only the GM can perform this action');
|
||||||
|
}
|
||||||
|
|
||||||
|
return campaign;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
server/src/modules/battle/dto/create-battle-map.dto.ts
Normal file
30
server/src/modules/battle/dto/create-battle-map.dto.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsString, IsInt, Min, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateBattleMapDto {
|
||||||
|
@ApiProperty({ description: 'Map name' })
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Grid columns', default: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
gridSizeX?: number = 20;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Grid rows', default: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
gridSizeY?: number = 20;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Grid X offset', default: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
gridOffsetX?: number = 0;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Grid Y offset', default: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
gridOffsetY?: number = 0;
|
||||||
|
}
|
||||||
19
server/src/modules/battle/dto/create-battle-session.dto.ts
Normal file
19
server/src/modules/battle/dto/create-battle-session.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsString, IsOptional, IsBoolean, IsInt, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateBattleSessionDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Session name' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Battle map ID' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
mapId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Whether the session is active', default: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean = false;
|
||||||
|
}
|
||||||
54
server/src/modules/battle/dto/create-battle-token.dto.ts
Normal file
54
server/src/modules/battle/dto/create-battle-token.dto.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsString, IsInt, IsNumber, IsOptional, IsArray, Min, Max } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateBattleTokenDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Combatant template ID (for NPCs/Monsters)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
combatantId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Character ID (for PCs)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
characterId?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Token display name' })
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'X position on grid' })
|
||||||
|
@IsNumber()
|
||||||
|
positionX: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Y position on grid' })
|
||||||
|
@IsNumber()
|
||||||
|
positionY: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Current HP' })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
hpCurrent: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Maximum HP' })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
hpMax: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Initiative value' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
initiative?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Active conditions', type: [String] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
conditions?: string[] = [];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Token size (1=Medium, 2=Large, etc.)', default: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(6)
|
||||||
|
size?: number = 1;
|
||||||
|
}
|
||||||
100
server/src/modules/battle/dto/create-combatant.dto.ts
Normal file
100
server/src/modules/battle/dto/create-combatant.dto.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsString, IsInt, IsOptional, IsEnum, Min, Max, IsArray, ValidateNested } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { CombatantType, ActionType } from '../../../generated/prisma/client.js';
|
||||||
|
|
||||||
|
export class CreateCombatantAbilityDto {
|
||||||
|
@ApiProperty({ description: 'Ability name' })
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Action cost (0 for free/reaction, 1-3 for actions)' })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(3)
|
||||||
|
actionCost: number;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['ACTION', 'REACTION', 'FREE'] })
|
||||||
|
@IsEnum(ActionType)
|
||||||
|
actionType: ActionType;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Ability description' })
|
||||||
|
@IsString()
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Damage dice (e.g. "2d6+4 slashing")' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
damage?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Ability traits', type: [String] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
traits?: string[] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateCombatantDto {
|
||||||
|
@ApiProperty({ description: 'Combatant name' })
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['PC', 'NPC', 'MONSTER'] })
|
||||||
|
@IsEnum(CombatantType)
|
||||||
|
type: CombatantType;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Combatant level' })
|
||||||
|
@IsInt()
|
||||||
|
@Min(-1)
|
||||||
|
@Max(30)
|
||||||
|
level: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Maximum HP' })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
hpMax: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Armor Class' })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
ac: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Fortitude save modifier' })
|
||||||
|
@IsInt()
|
||||||
|
fortitude: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Reflex save modifier' })
|
||||||
|
@IsInt()
|
||||||
|
reflex: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Will save modifier' })
|
||||||
|
@IsInt()
|
||||||
|
will: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Perception modifier' })
|
||||||
|
@IsInt()
|
||||||
|
perception: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Movement speed in feet', default: 25 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
speed?: number = 25;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Avatar image URL' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
avatarUrl?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Description/notes' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Combat abilities', type: [CreateCombatantAbilityDto] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreateCombatantAbilityDto)
|
||||||
|
abilities?: CreateCombatantAbilityDto[] = [];
|
||||||
|
}
|
||||||
7
server/src/modules/battle/dto/index.ts
Normal file
7
server/src/modules/battle/dto/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export * from './create-battle-session.dto';
|
||||||
|
export * from './update-battle-session.dto';
|
||||||
|
export * from './create-battle-map.dto';
|
||||||
|
export * from './update-battle-map.dto';
|
||||||
|
export * from './create-battle-token.dto';
|
||||||
|
export * from './update-battle-token.dto';
|
||||||
|
export * from './create-combatant.dto';
|
||||||
31
server/src/modules/battle/dto/update-battle-map.dto.ts
Normal file
31
server/src/modules/battle/dto/update-battle-map.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsString, IsInt, Min, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateBattleMapDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Map name' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Grid columns' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
gridSizeX?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Grid rows' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
gridSizeY?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Grid X offset' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
gridOffsetX?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Grid Y offset' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
gridOffsetY?: number;
|
||||||
|
}
|
||||||
25
server/src/modules/battle/dto/update-battle-session.dto.ts
Normal file
25
server/src/modules/battle/dto/update-battle-session.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsString, IsOptional, IsBoolean, IsInt, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateBattleSessionDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Session name' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Battle map ID' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
mapId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Whether the session is active' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Current round number' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
roundNumber?: number;
|
||||||
|
}
|
||||||
49
server/src/modules/battle/dto/update-battle-token.dto.ts
Normal file
49
server/src/modules/battle/dto/update-battle-token.dto.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsString, IsInt, IsNumber, IsOptional, IsArray, Min, Max } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateBattleTokenDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Token display name' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'X position on grid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
positionX?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Y position on grid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
positionY?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Current HP' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
hpCurrent?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Maximum HP' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
hpMax?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Initiative value' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
initiative?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Active conditions', type: [String] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
conditions?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Token size (1=Medium, 2=Large, etc.)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(6)
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user