diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..2acf542
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,8 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(npx tsc:*)",
+ "Bash(npm install:*)"
+ ]
+ }
+}
diff --git a/PROGRESS.md b/PROGRESS.md
new file mode 100644
index 0000000..d08c968
--- /dev/null
+++ b/PROGRESS.md
@@ -0,0 +1,140 @@
+# Dimension 47 - Projektfortschritt
+
+## Abgeschlossen
+
+### Backend (NestJS + Prisma 7)
+
+- **Auth-Modul**
+ - JWT-basierte Authentifizierung
+ - Login/Register Endpoints
+ - Benutzerprofile mit Avatar
+ - Benutzersuche
+ - Globale Auth Guards
+
+- **Kampagnen-Modul**
+ - CRUD-Operationen für Kampagnen
+ - Mitgliederverwaltung (hinzufügen/entfernen)
+ - GM-Berechtigungen
+
+- **Charaktere-Modul**
+ - CRUD-Operationen für Charaktere (PC/NPC)
+ - HP-Tracking (aktuell, max, temporär)
+ - Zustände (Conditions) verwalten
+ - Pathbuilder 2e Import mit automatischer Übersetzung
+ - Abilities, Skills, Feats, Items, Resources Import
+
+- **Übersetzungs-Modul**
+ - Claude API Integration für Deutsch-Übersetzungen
+ - Datenbank-Caching für Übersetzungen
+ - Batch-Übersetzung für Performance
+
+- **Prisma Schema**
+ - User, Campaign, CampaignMember
+ - Character mit allen Relationen
+ - CharacterAbility, CharacterSkill, CharacterFeat
+ - CharacterItem, CharacterResource, CharacterCondition
+ - Translation (Cache)
+
+### Frontend (React + Vite + TypeScript)
+
+- **Auth**
+ - Login-Seite mit Logo
+ - Registrierung
+ - Auth-Store (Zustand)
+ - Protected Routes
+
+- **Layout**
+ - Navbar mit Logo + "Dimension 47" (Cinzel Font)
+ - Footer mit Zeasy Logo (verlinkt)
+ - Responsive Design
+ - Dark Theme
+
+- **Kampagnen**
+ - Dashboard mit Kampagnen-Übersicht
+ - Kampagnen-Detailseite
+ - Kampagne erstellen/bearbeiten/löschen
+ - Mitglieder verwalten
+
+- **Charaktere**
+ - Charakter erstellen (manuell)
+ - Pathbuilder JSON Import (Datei-Upload + Paste)
+ - Charakter-Vorschau vor Import
+ - Charakterliste in Kampagne
+
+- **UI-Komponenten**
+ - Button, Card, Input, Spinner
+ - Modal-System
+ - Einheitliches Design-System
+
+---
+
+## In Arbeit / Geplant
+
+### Hohe Priorität
+
+- [ ] **Charakterbogen-Ansicht**
+ - Vollständige Anzeige aller importierten Daten
+ - Abilities, Skills, Feats, Items anzeigen
+ - HP bearbeiten
+ - Zustände hinzufügen/entfernen
+
+- [ ] **Kampfbildschirm (Battle Tracker)**
+ - Initiative-Tracking
+ - Runden-Management
+ - HP-Änderungen im Kampf
+ - Zustände im Kampf verwalten
+
+### Mittlere Priorität
+
+- [ ] **Zauber-System**
+ - Zauberslots verwalten
+ - Zauber casten/vorbereiten
+ - Fokuspunkte tracking
+
+- [ ] **Inventar-Management**
+ - Items hinzufügen/entfernen
+ - Bulk-Berechnung
+ - Geld verwalten
+
+- [ ] **Würfel-System**
+ - Würfelwürfe mit Modifikatoren
+ - Skill-Checks
+ - Angriffswürfe
+
+### Niedrige Priorität
+
+- [ ] **Dokumente**
+ - Kampagnen-Dokumente hochladen
+ - Notizen teilen
+
+- [ ] **Notizen-System**
+ - Persönliche Notizen
+ - Geteilte Kampagnen-Notizen
+
+- [ ] **WebSocket Integration**
+ - Echtzeit-Updates für alle Spieler
+ - Live HP/Zustands-Änderungen
+
+- [ ] **Encounter Builder**
+ - Monster/NPCs aus Datenbank
+ - Encounter-Schwierigkeit berechnen
+
+---
+
+## Technische Schulden
+
+- [ ] Unit Tests hinzufügen
+- [ ] E2E Tests hinzufügen
+- [ ] API-Dokumentation (Swagger)
+- [ ] Error Boundary im Frontend
+- [ ] Logging verbessern
+
+---
+
+## Letzte Änderungen
+
+**2025-01-18**
+- Pathbuilder Import implementiert
+- Claude API für Übersetzungen integriert
+- Import-Modal im Frontend erstellt
+- Logo-Integration abgeschlossen
diff --git a/client/Logos/Logo_Horizontal.svg b/client/Logos/Logo_Horizontal.svg
new file mode 100644
index 0000000..ad7bec8
--- /dev/null
+++ b/client/Logos/Logo_Horizontal.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/Logos/Logo_Vertikal.svg b/client/Logos/Logo_Vertikal.svg
new file mode 100644
index 0000000..3270feb
--- /dev/null
+++ b/client/Logos/Logo_Vertikal.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/Logos/Logo_ohne_Text.svg b/client/Logos/Logo_ohne_Text.svg
new file mode 100644
index 0000000..a0b81a6
--- /dev/null
+++ b/client/Logos/Logo_ohne_Text.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/Logos/Zeasy/logo.svg b/client/Logos/Zeasy/logo.svg
new file mode 100644
index 0000000..d6eee44
--- /dev/null
+++ b/client/Logos/Zeasy/logo.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/client/Logos/Zeasy/logo_text_horizontal.svg b/client/Logos/Zeasy/logo_text_horizontal.svg
new file mode 100644
index 0000000..a50da9f
--- /dev/null
+++ b/client/Logos/Zeasy/logo_text_horizontal.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/client/Logos/Zeasy/logo_text_vertical.svg b/client/Logos/Zeasy/logo_text_vertical.svg
new file mode 100644
index 0000000..99a620e
--- /dev/null
+++ b/client/Logos/Zeasy/logo_text_vertical.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/client/index.html b/client/index.html
index 94e7284..5e933da 100644
--- a/client/index.html
+++ b/client/index.html
@@ -8,7 +8,7 @@
-
+
Dimension47
diff --git a/client/package-lock.json b/client/package-lock.json
index 70a1480..7a2714f 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -68,7 +68,6 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -1747,7 +1746,6 @@
"integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -1758,7 +1756,6 @@
"integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1818,7 +1815,6 @@
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.53.0",
"@typescript-eslint/types": "8.53.0",
@@ -2070,7 +2066,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2193,7 +2188,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2575,7 +2569,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3763,7 +3756,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -3831,7 +3823,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3841,7 +3832,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -4147,7 +4137,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4234,7 +4223,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -4385,7 +4373,6 @@
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"dev": true,
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/client/src/App.tsx b/client/src/App.tsx
index b8bc55e..755a16f 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -2,7 +2,8 @@ import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { LoginPage, RegisterPage, useAuthStore } from '@/features/auth';
-import { CampaignsPage } from '@/features/campaigns';
+import { CampaignsPage, CampaignDetailPage } from '@/features/campaigns';
+import { CharacterSheetPage } from '@/features/characters';
import { ProtectedRoute } from '@/shared/components/protected-route';
import { Layout } from '@/shared/components/layout';
@@ -43,7 +44,8 @@ function AppContent() {
}>
}>
} />
- Campaign Detail (TODO)} />
+ } />
+ } />
Library (TODO)} />
diff --git a/client/src/features/auth/components/login-page.tsx b/client/src/features/auth/components/login-page.tsx
index 1092523..0431313 100644
--- a/client/src/features/auth/components/login-page.tsx
+++ b/client/src/features/auth/components/login-page.tsx
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { Button, Input, Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/shared/components/ui';
import { useAuthStore } from '../hooks/use-auth-store';
+import LogoVertical from '../../../../Logos/Logo_Vertikal.svg';
export function LoginPage() {
const navigate = useNavigate();
@@ -30,26 +31,17 @@ export function LoginPage() {
};
return (
-
+
+ {/* Logo */}
+

+
-
- Willkommen zur\u00fcck
+ Willkommen zurück
Melde dich an, um fortzufahren
diff --git a/client/src/features/auth/components/register-page.tsx b/client/src/features/auth/components/register-page.tsx
index 3c36328..0cbca4c 100644
--- a/client/src/features/auth/components/register-page.tsx
+++ b/client/src/features/auth/components/register-page.tsx
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { Button, Input, Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/shared/components/ui';
import { useAuthStore } from '../hooks/use-auth-store';
+import LogoVertical from '../../../../Logos/Logo_Vertikal.svg';
export function RegisterPage() {
const navigate = useNavigate();
@@ -41,25 +42,16 @@ export function RegisterPage() {
};
return (
-
+
+ {/* Logo */}
+

+
-
Konto erstellen
Registriere dich f\u00fcr Dimension47
diff --git a/client/src/features/campaigns/components/add-member-modal.tsx b/client/src/features/campaigns/components/add-member-modal.tsx
new file mode 100644
index 0000000..a2f8ca3
--- /dev/null
+++ b/client/src/features/campaigns/components/add-member-modal.tsx
@@ -0,0 +1,111 @@
+import { useState } from 'react';
+import { X, Search, UserPlus } from 'lucide-react';
+import { Button, Input, Spinner } from '@/shared/components/ui';
+import { api } from '@/shared/lib/api';
+import type { User } from '@/shared/types';
+
+interface AddMemberModalProps {
+ campaignId: string;
+ onClose: () => void;
+ onMemberAdded: () => void;
+}
+
+export function AddMemberModal({ campaignId, onClose, onMemberAdded }: AddMemberModalProps) {
+ const [searchQuery, setSearchQuery] = useState('');
+ const [searchResults, setSearchResults] = useState([]);
+ const [isSearching, setIsSearching] = useState(false);
+ const [isAdding, setIsAdding] = useState(null);
+
+ const handleSearch = async () => {
+ if (searchQuery.length < 2) return;
+ setIsSearching(true);
+ try {
+ const results = await api.searchUsers(searchQuery);
+ setSearchResults(results);
+ } catch (error) {
+ console.error('Failed to search users:', error);
+ } finally {
+ setIsSearching(false);
+ }
+ };
+
+ const handleAddMember = async (userId: string) => {
+ setIsAdding(userId);
+ try {
+ await api.addCampaignMember(campaignId, userId);
+ onMemberAdded();
+ onClose();
+ } catch (error) {
+ console.error('Failed to add member:', error);
+ setIsAdding(null);
+ }
+ };
+
+ return (
+
+
+
+
+
Mitglied hinzufügen
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
+ />
+
+
+
+ {searchResults.length > 0 && (
+
+ {searchResults.map((user) => (
+
+
+
+
+ {user.username.charAt(0).toUpperCase()}
+
+
+
+
{user.username}
+
{user.email}
+
+
+
+
+ ))}
+
+ )}
+
+ {searchQuery.length >= 2 && searchResults.length === 0 && !isSearching && (
+
+ Keine Benutzer gefunden
+
+ )}
+
+
+
+ );
+}
diff --git a/client/src/features/campaigns/components/campaign-detail-page.tsx b/client/src/features/campaigns/components/campaign-detail-page.tsx
new file mode 100644
index 0000000..6d6c62f
--- /dev/null
+++ b/client/src/features/campaigns/components/campaign-detail-page.tsx
@@ -0,0 +1,337 @@
+import { useState, useEffect } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import {
+ ArrowLeft,
+ Settings,
+ Users,
+ Swords,
+ UserPlus,
+ Trash2,
+ Crown,
+ Shield,
+ Heart,
+ FileJson
+} from 'lucide-react';
+import {
+ Button,
+ Card,
+ CardHeader,
+ CardTitle,
+ CardContent,
+ Spinner
+} from '@/shared/components/ui';
+import { api } from '@/shared/lib/api';
+import { useAuthStore } from '@/features/auth';
+import type { Campaign, CharacterSummary } from '@/shared/types';
+import { AddMemberModal } from './add-member-modal';
+import { EditCampaignModal } from './edit-campaign-modal';
+import { CreateCharacterModal, ImportCharacterModal } from '@/features/characters';
+
+export function CampaignDetailPage() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const { user } = useAuthStore();
+
+ const [campaign, setCampaign] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [showAddMember, setShowAddMember] = useState(false);
+ const [showEditCampaign, setShowEditCampaign] = useState(false);
+ const [showCreateCharacter, setShowCreateCharacter] = useState(false);
+ const [showImportCharacter, setShowImportCharacter] = useState(false);
+
+ const isGM = campaign?.gmId === user?.id;
+ const isAdmin = user?.role === 'ADMIN';
+ const canManage = isGM || isAdmin;
+
+ const fetchCampaign = async () => {
+ if (!id) return;
+ try {
+ const data = await api.getCampaign(id);
+ setCampaign(data);
+ } catch (error) {
+ console.error('Failed to fetch campaign:', error);
+ navigate('/');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchCampaign();
+ }, [id]);
+
+ const handleRemoveMember = async (userId: string) => {
+ if (!id || !confirm('Mitglied wirklich entfernen?')) return;
+ try {
+ await api.removeCampaignMember(id, userId);
+ fetchCampaign();
+ } catch (error) {
+ console.error('Failed to remove member:', error);
+ }
+ };
+
+ const handleDeleteCampaign = async () => {
+ if (!id || !confirm('Kampagne wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.')) return;
+ try {
+ await api.deleteCampaign(id);
+ navigate('/');
+ } catch (error) {
+ console.error('Failed to delete campaign:', error);
+ }
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!campaign) {
+ return (
+
+
Kampagne nicht gefunden
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
{campaign.name}
+ {campaign.description && (
+
{campaign.description}
+ )}
+
+
+ GM: {campaign.gm.username}
+
+
+
+ {canManage && (
+
+
+
+
+ )}
+
+
+ {/* Campaign Image */}
+ {campaign.imageUrl && (
+
+

+
+ )}
+
+
+ {/* Members Section */}
+
+
+
+
+ Mitglieder ({campaign.members.length})
+
+ {canManage && (
+
+ )}
+
+
+
+ {campaign.members.map((member) => (
+
+
+
+ {member.user.avatarUrl ? (
+

+ ) : (
+
+ {member.user.username.charAt(0).toUpperCase()}
+
+ )}
+
+
+
+ {member.user.username}
+ {member.userId === campaign.gmId && (
+
+ )}
+
+
{member.user.email}
+
+
+ {canManage && member.userId !== campaign.gmId && (
+
+ )}
+
+ ))}
+
+
+
+
+ {/* Characters Section */}
+
+
+
+
+ Charaktere ({campaign.characters?.length || 0})
+
+
+
+
+
+
+
+ {!campaign.characters?.length ? (
+
+
+
Noch keine Charaktere
+
+ ) : (
+
+ {campaign.characters.map((character: CharacterSummary) => (
+
navigate(`/campaigns/${id}/characters/${character.id}`)}
+ >
+
+
+ {character.avatarUrl ? (
+

+ ) : (
+
+ )}
+
+
+
{character.name}
+
+ Level {character.level} {character.type}
+ {character.owner && ` • ${character.owner.username}`}
+
+
+
+
+
+
+ {character.hpCurrent}/{character.hpMax}
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ {/* Quick Actions */}
+
+
navigate(`/campaigns/${id}/battle`)}>
+
+
+
+
+
+
Kampfbildschirm
+
Kämpfe verwalten
+
+
+
+
+
+
+
+
+
+
Dokumente
+
Bald verfügbar
+
+
+
+
+
+
+
+
+
+
Notizen
+
Bald verfügbar
+
+
+
+
+
+ {/* Modals */}
+ {showAddMember && (
+
setShowAddMember(false)}
+ onMemberAdded={fetchCampaign}
+ />
+ )}
+ {showEditCampaign && (
+ setShowEditCampaign(false)}
+ onUpdated={fetchCampaign}
+ />
+ )}
+ {showCreateCharacter && (
+ setShowCreateCharacter(false)}
+ onCreated={fetchCampaign}
+ />
+ )}
+ {showImportCharacter && (
+ setShowImportCharacter(false)}
+ onImported={fetchCampaign}
+ />
+ )}
+
+ );
+}
diff --git a/client/src/features/campaigns/components/edit-campaign-modal.tsx b/client/src/features/campaigns/components/edit-campaign-modal.tsx
new file mode 100644
index 0000000..b9937cf
--- /dev/null
+++ b/client/src/features/campaigns/components/edit-campaign-modal.tsx
@@ -0,0 +1,109 @@
+import { useState } from 'react';
+import { X } from 'lucide-react';
+import { Button, Input, Spinner } from '@/shared/components/ui';
+import { api } from '@/shared/lib/api';
+import type { Campaign } from '@/shared/types';
+
+interface EditCampaignModalProps {
+ campaign: Campaign;
+ onClose: () => void;
+ onUpdated: () => void;
+}
+
+export function EditCampaignModal({ campaign, onClose, onUpdated }: EditCampaignModalProps) {
+ const [name, setName] = useState(campaign.name);
+ const [description, setDescription] = useState(campaign.description || '');
+ const [imageUrl, setImageUrl] = useState(campaign.imageUrl || '');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [error, setError] = useState('');
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!name.trim()) {
+ setError('Name ist erforderlich');
+ return;
+ }
+
+ setIsSubmitting(true);
+ setError('');
+
+ try {
+ await api.updateCampaign(campaign.id, {
+ name: name.trim(),
+ description: description.trim() || undefined,
+ imageUrl: imageUrl.trim() || undefined,
+ });
+ onUpdated();
+ onClose();
+ } catch (err) {
+ setError('Fehler beim Aktualisieren der Kampagne');
+ console.error('Failed to update campaign:', err);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+
Kampagne bearbeiten
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/features/campaigns/index.ts b/client/src/features/campaigns/index.ts
index 7e92446..aa9ad0e 100644
--- a/client/src/features/campaigns/index.ts
+++ b/client/src/features/campaigns/index.ts
@@ -1,2 +1,5 @@
export { CampaignsPage } from './components/campaigns-page';
+export { CampaignDetailPage } from './components/campaign-detail-page';
export { CreateCampaignModal } from './components/create-campaign-modal';
+export { AddMemberModal } from './components/add-member-modal';
+export { EditCampaignModal } from './components/edit-campaign-modal';
diff --git a/client/src/features/characters/components/character-sheet-page.tsx b/client/src/features/characters/components/character-sheet-page.tsx
new file mode 100644
index 0000000..6745f9f
--- /dev/null
+++ b/client/src/features/characters/components/character-sheet-page.tsx
@@ -0,0 +1,718 @@
+import { useState, useEffect } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import {
+ ArrowLeft,
+ Heart,
+ Shield,
+ Zap,
+ Swords,
+ BookOpen,
+ Package,
+ AlertCircle,
+ Edit2,
+ Trash2,
+ Plus,
+ Minus,
+ Sparkles,
+ FlaskConical,
+ User,
+ Star,
+} from 'lucide-react';
+import {
+ Button,
+ Card,
+ CardHeader,
+ CardTitle,
+ CardContent,
+ Spinner,
+ Input,
+} from '@/shared/components/ui';
+import { api } from '@/shared/lib/api';
+import { useAuthStore } from '@/features/auth';
+import type { Character } from '@/shared/types';
+
+type TabType = 'status' | 'inventory' | 'feats' | 'spells' | 'alchemy' | 'actions';
+
+const TABS: { id: TabType; label: string; icon: React.ReactNode }[] = [
+ { id: 'status', label: 'Status', icon: },
+ { id: 'inventory', label: 'Inventar', icon: },
+ { id: 'feats', label: 'Talente', icon: },
+ { id: 'spells', label: 'Zauber', icon: },
+ { id: 'alchemy', label: 'Alchemie', icon: },
+ { id: 'actions', label: 'Aktionen', icon: },
+];
+
+const ABILITY_NAMES: Record = {
+ STR: 'Stärke',
+ DEX: 'Geschicklichkeit',
+ CON: 'Konstitution',
+ INT: 'Intelligenz',
+ WIS: 'Weisheit',
+ CHA: 'Charisma',
+};
+
+const PROFICIENCY_NAMES: Record = {
+ UNTRAINED: 'Ungeübt',
+ TRAINED: 'Geübt',
+ EXPERT: 'Experte',
+ MASTER: 'Meister',
+ LEGENDARY: 'Legendär',
+};
+
+const PROFICIENCY_COLORS: Record = {
+ UNTRAINED: 'text-text-secondary',
+ TRAINED: 'text-blue-500',
+ EXPERT: 'text-purple-500',
+ MASTER: 'text-orange-500',
+ LEGENDARY: 'text-red-500',
+};
+
+export function CharacterSheetPage() {
+ const { id: campaignId, characterId } = useParams<{ id: string; characterId: string }>();
+ const navigate = useNavigate();
+ const { user } = useAuthStore();
+
+ const [character, setCharacter] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [activeTab, setActiveTab] = useState('status');
+ const [hpEdit, setHpEdit] = useState(null);
+
+ const isOwner = character?.ownerId === user?.id;
+
+ const fetchCharacter = async () => {
+ if (!campaignId || !characterId) return;
+ try {
+ const data = await api.getCharacter(campaignId, characterId);
+ setCharacter(data);
+ } catch (error) {
+ console.error('Failed to fetch character:', error);
+ navigate(`/campaigns/${campaignId}`);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchCharacter();
+ }, [campaignId, characterId]);
+
+ const handleHpChange = async (delta: number) => {
+ if (!character || !campaignId) return;
+ const newHp = Math.max(0, Math.min(character.hpMax, character.hpCurrent + delta));
+ try {
+ await api.updateCharacterHp(campaignId, character.id, newHp);
+ setCharacter({ ...character, hpCurrent: newHp });
+ } catch (error) {
+ console.error('Failed to update HP:', error);
+ }
+ };
+
+ const handleHpSet = async () => {
+ if (hpEdit === null || !character || !campaignId) return;
+ const newHp = Math.max(0, Math.min(character.hpMax, hpEdit));
+ try {
+ await api.updateCharacterHp(campaignId, character.id, newHp);
+ setCharacter({ ...character, hpCurrent: newHp });
+ setHpEdit(null);
+ } catch (error) {
+ console.error('Failed to update HP:', error);
+ }
+ };
+
+ const handleDelete = async () => {
+ if (!character || !campaignId || !confirm('Charakter wirklich löschen?')) return;
+ try {
+ await api.deleteCharacter(campaignId, character.id);
+ navigate(`/campaigns/${campaignId}`);
+ } catch (error) {
+ console.error('Failed to delete character:', error);
+ }
+ };
+
+ const handleRemoveCondition = async (conditionId: string) => {
+ if (!character || !campaignId) return;
+ try {
+ await api.removeCharacterCondition(campaignId, character.id, conditionId);
+ setCharacter({
+ ...character,
+ conditions: character.conditions.filter((c) => c.id !== conditionId),
+ });
+ } catch (error) {
+ console.error('Failed to remove condition:', error);
+ }
+ };
+
+ const getAbilityModifier = (score: number) => {
+ const mod = Math.floor((score - 10) / 2);
+ return mod >= 0 ? `+${mod}` : `${mod}`;
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!character) {
+ return (
+
+
Charakter nicht gefunden
+
+ );
+ }
+
+ const hpPercentage = (character.hpCurrent / character.hpMax) * 100;
+
+ // Tab Content Renderers
+ const renderStatusTab = () => (
+
+ {/* HP Bar */}
+
+
+
+
+
+
+
Trefferpunkte
+
+ {hpEdit !== null ? (
+ <>
+
setHpEdit(parseInt(e.target.value) || 0)}
+ className="w-20 h-8 text-center"
+ onKeyDown={(e) => e.key === 'Enter' && handleHpSet()}
+ />
+
+
+ >
+ ) : (
+ <>
+
+
setHpEdit(character.hpCurrent)}
+ >
+ {character.hpCurrent} / {character.hpMax}
+
+
+ >
+ )}
+ {character.hpTemp > 0 && (
+
(+{character.hpTemp} temp)
+ )}
+
+
+
+
+
+
+
+
+ {/* Abilities */}
+
+
+
+
+ Attribute
+
+
+
+
+ {character.abilities.map((ability) => (
+
+
+ {ABILITY_NAMES[ability.ability]}
+
+
+ {getAbilityModifier(ability.score)}
+
+
{ability.score}
+
+ ))}
+
+
+
+
+ {/* Conditions */}
+
+
+
+
+ Zustände ({character.conditions.length})
+
+
+
+
+ {character.conditions.length === 0 ? (
+ Keine aktiven Zustände
+ ) : (
+
+ {character.conditions.map((condition) => (
+
+
+ {condition.nameGerman || condition.name}
+ {condition.value && ` ${condition.value}`}
+
+
+
+ ))}
+
+ )}
+
+
+
+ {/* Skills */}
+
+
+
+
+ Fertigkeiten ({character.skills.length})
+
+
+
+
+ {character.skills.map((skill) => (
+
+ {skill.skillName}
+
+ {PROFICIENCY_NAMES[skill.proficiency]}
+
+
+ ))}
+
+
+
+
+ );
+
+ const renderInventoryTab = () => (
+
+ {/* Equipped Items */}
+
+
+
+
+ Ausrüstung
+
+
+
+
+ {character.items.filter(i => i.equipped).length === 0 ? (
+ Keine ausgerüsteten Gegenstände
+ ) : (
+
+ {character.items.filter(i => i.equipped).map((item) => (
+
+
+
+ {item.nameGerman || item.name}
+
+
+ Ausgerüstet
+
+
+ {item.notes && (
+
{item.notes}
+ )}
+
+ ))}
+
+ )}
+
+
+
+ {/* All Items */}
+
+
+
+
+ Inventar ({character.items.length})
+
+
+
+ {character.items.length === 0 ? (
+ Keine Gegenstände
+ ) : (
+
+ {character.items.filter(i => !i.equipped).map((item) => (
+
+
+
+ {item.nameGerman || item.name}
+ {item.quantity > 1 && ` (×${item.quantity})`}
+
+
+ {item.notes && (
+
{item.notes}
+ )}
+
+ ))}
+
+ )}
+
+
+
+ );
+
+ const renderFeatsTab = () => (
+
+
+
+
+
+ Talente ({character.feats.length})
+
+
+
+
+ {character.feats.length === 0 ? (
+ Keine Talente
+ ) : (
+
+ {character.feats.map((feat) => (
+
+
+
+
+ {feat.nameGerman || feat.name}
+
+
+ Level {feat.level}
+
+
+
+ {feat.source}
+
+
+
+ ))}
+
+ )}
+
+
+
+ );
+
+ const renderSpellsTab = () => (
+
+
+
+
+
+ Zauber ({character.spells.length})
+
+
+
+
+ {character.spells.length === 0 ? (
+ Keine Zauber
+ ) : (
+
+ {/* Group by spell level */}
+ {[...new Set(character.spells.map(s => s.spellLevel))].sort().map(level => (
+
+
+ {level === 0 ? 'Zaubertricks' : `Grad ${level}`}
+
+
+ {character.spells.filter(s => s.spellLevel === level).map((spell) => (
+
+
+
+
+ {spell.nameGerman || spell.name}
+
+
+
+ {spell.tradition}
+
+
+ ))}
+
+
+ ))}
+
+ )}
+
+
+
+ );
+
+ const renderAlchemyTab = () => (
+
+
+
+
+
+ Alchemie-Ressourcen
+
+
+
+
+ {character.resources.filter(r => r.name.toLowerCase().includes('vial') || r.name.toLowerCase().includes('alchemy')).length === 0 ? (
+
+ Keine Alchemie-Ressourcen verfügbar
+
+ ) : (
+ character.resources.filter(r => r.name.toLowerCase().includes('vial') || r.name.toLowerCase().includes('alchemy')).map((resource) => (
+
+
{resource.name}
+
+
+
+ {resource.current} / {resource.max}
+
+
+
+
+ ))
+ )}
+
+
+
+
+ {/* Alchemical Items from Inventory */}
+
+
+ Alchemistische Gegenstände
+
+
+ {character.items.filter(i => i.name.toLowerCase().includes('bomb') || i.name.toLowerCase().includes('elixir') || i.name.toLowerCase().includes('mutagen')).length === 0 ? (
+
+ Keine alchemistischen Gegenstände
+
+ ) : (
+
+ {character.items.filter(i => i.name.toLowerCase().includes('bomb') || i.name.toLowerCase().includes('elixir') || i.name.toLowerCase().includes('mutagen')).map((item) => (
+
+
+ {item.nameGerman || item.name}
+ {item.quantity > 1 && ` (×${item.quantity})`}
+
+
+ ))}
+
+ )}
+
+
+
+ );
+
+ const renderActionsTab = () => {
+ const basicActions = [
+ { name: 'Angriff', actions: 1, type: 'Aktion' },
+ { name: 'Schritt', actions: 1, type: 'Aktion' },
+ { name: 'Bewegung', actions: 1, type: 'Aktion' },
+ { name: 'Interagieren', actions: 1, type: 'Aktion' },
+ { name: 'Schild heben', actions: 1, type: 'Aktion' },
+ { name: 'Aufstehen', actions: 1, type: 'Aktion' },
+ { name: 'Springen', actions: 1, type: 'Aktion' },
+ { name: 'Fallenlassen', actions: 0, type: 'Freie Aktion' },
+ { name: 'Verzögern', actions: 0, type: 'Freie Aktion' },
+ { name: 'Gelegenheitsangriff', actions: -1, type: 'Reaktion' },
+ ];
+
+ return (
+
+
+
+
+
+ Grundaktionen
+
+
+
+
+ {basicActions.map((action) => (
+
+
{action.name}
+
+ {action.actions === -1 ? (
+
+ Reaktion
+
+ ) : action.actions === 0 ? (
+
+ Frei
+
+ ) : (
+
+ {[...Array(action.actions)].map((_, i) => (
+
+ ))}
+
+ )}
+
+
+ ))}
+
+
+
+
+ {/* Character-specific actions from feats */}
+ {character.feats.filter(f => f.source === 'CLASS').length > 0 && (
+
+
+ Klassenaktionen
+
+
+
+ {character.feats.filter(f => f.source === 'CLASS').map((feat) => (
+
+
+ {feat.nameGerman || feat.name}
+
+
+ ))}
+
+
+
+ )}
+
+ );
+ };
+
+ const renderTabContent = () => {
+ switch (activeTab) {
+ case 'status':
+ return renderStatusTab();
+ case 'inventory':
+ return renderInventoryTab();
+ case 'feats':
+ return renderFeatsTab();
+ case 'spells':
+ return renderSpellsTab();
+ case 'alchemy':
+ return renderAlchemyTab();
+ case 'actions':
+ return renderActionsTab();
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+ {character.avatarUrl ? (
+

+ ) : (
+
+ )}
+
+
+
{character.name}
+
+ Level {character.level} {character.type}
+
+
+
+
+ {isOwner && (
+
+
+
+
+ )}
+
+
+ {/* Tab Navigation */}
+
+ {TABS.map((tab) => (
+
+ ))}
+
+
+ {/* Tab Content */}
+
+ {renderTabContent()}
+
+
+ );
+}
diff --git a/client/src/features/characters/components/create-character-modal.tsx b/client/src/features/characters/components/create-character-modal.tsx
new file mode 100644
index 0000000..367cedf
--- /dev/null
+++ b/client/src/features/characters/components/create-character-modal.tsx
@@ -0,0 +1,137 @@
+import { useState } from 'react';
+import { X } from 'lucide-react';
+import { Button, Input, Spinner } from '@/shared/components/ui';
+import { api } from '@/shared/lib/api';
+
+interface CreateCharacterModalProps {
+ campaignId: string;
+ onClose: () => void;
+ onCreated: () => void;
+}
+
+export function CreateCharacterModal({ campaignId, onClose, onCreated }: CreateCharacterModalProps) {
+ const [name, setName] = useState('');
+ const [type, setType] = useState<'PC' | 'NPC'>('PC');
+ const [level, setLevel] = useState(1);
+ const [hpMax, setHpMax] = useState(20);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [error, setError] = useState('');
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!name.trim()) {
+ setError('Name ist erforderlich');
+ return;
+ }
+
+ setIsSubmitting(true);
+ setError('');
+
+ try {
+ await api.createCharacter(campaignId, {
+ name: name.trim(),
+ type,
+ level,
+ hpCurrent: hpMax,
+ hpMax,
+ });
+ onCreated();
+ onClose();
+ } catch (err) {
+ setError('Fehler beim Erstellen des Charakters');
+ console.error('Failed to create character:', err);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+
Neuer Charakter
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/features/characters/components/import-character-modal.tsx b/client/src/features/characters/components/import-character-modal.tsx
new file mode 100644
index 0000000..d74cf4d
--- /dev/null
+++ b/client/src/features/characters/components/import-character-modal.tsx
@@ -0,0 +1,245 @@
+import { useState, useRef } from 'react';
+import { X, Upload, FileJson, AlertCircle, CheckCircle } from 'lucide-react';
+import { Button, Spinner } from '@/shared/components/ui';
+import { api } from '@/shared/lib/api';
+
+interface ImportCharacterModalProps {
+ campaignId: string;
+ onClose: () => void;
+ onImported: () => void;
+}
+
+interface PathbuilderJson {
+ success: boolean;
+ build: {
+ name: string;
+ class: string;
+ level: number;
+ ancestry: string;
+ heritage: string;
+ background: string;
+ };
+}
+
+export function ImportCharacterModal({ campaignId, onClose, onImported }: ImportCharacterModalProps) {
+ const [jsonText, setJsonText] = useState('');
+ const [parsedData, setParsedData] = useState(null);
+ const [error, setError] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [importSuccess, setImportSuccess] = useState(false);
+ const fileInputRef = useRef(null);
+
+ const validateAndParseJson = (text: string) => {
+ try {
+ const data = JSON.parse(text);
+
+ // Validate Pathbuilder structure
+ if (!data.build) {
+ throw new Error('Kein "build" Objekt gefunden. Ist das eine Pathbuilder Export-Datei?');
+ }
+
+ if (!data.build.name || !data.build.class || !data.build.level) {
+ throw new Error('Fehlende Pflichtfelder (name, class, level)');
+ }
+
+ setParsedData(data);
+ setError('');
+ return data;
+ } catch (err) {
+ if (err instanceof SyntaxError) {
+ setError('Ungültiges JSON Format');
+ } else if (err instanceof Error) {
+ setError(err.message);
+ }
+ setParsedData(null);
+ return null;
+ }
+ };
+
+ const handleTextChange = (text: string) => {
+ setJsonText(text);
+ if (text.trim()) {
+ validateAndParseJson(text);
+ } else {
+ setParsedData(null);
+ setError('');
+ }
+ };
+
+ const handleFileUpload = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ if (file.size > 1024 * 1024) {
+ setError('Datei ist zu groß (max. 1MB)');
+ return;
+ }
+
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ const text = event.target?.result as string;
+ setJsonText(text);
+ validateAndParseJson(text);
+ };
+ reader.onerror = () => {
+ setError('Fehler beim Lesen der Datei');
+ };
+ reader.readAsText(file);
+ };
+
+ const handleImport = async () => {
+ if (!parsedData) return;
+
+ setIsSubmitting(true);
+ setError('');
+
+ try {
+ await api.importCharacterFromPathbuilder(campaignId, parsedData);
+ setImportSuccess(true);
+ setTimeout(() => {
+ onImported();
+ onClose();
+ }, 1500);
+ } catch (err) {
+ console.error('Import failed:', err);
+ setError('Import fehlgeschlagen. Bitte versuche es erneut.');
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ Pathbuilder Import
+
+
+
+
+ {importSuccess ? (
+
+
+
+ Import erfolgreich!
+
+
+ {parsedData?.build.name} wurde importiert.
+
+
+ ) : (
+ <>
+ {/* File Upload */}
+
+
+
+
+
+
+
+ {/* JSON Text Area */}
+
+
+
+
+ {/* Error Message */}
+ {error && (
+
+ )}
+
+ {/* Preview */}
+ {parsedData && (
+
+
Vorschau
+
+
+ Name:{' '}
+ {parsedData.build.name}
+
+
+ Level:{' '}
+ {parsedData.build.level}
+
+
+ Klasse:{' '}
+ {parsedData.build.class}
+
+
+ Abstammung:{' '}
+ {parsedData.build.ancestry}
+
+
+ Erbe:{' '}
+ {parsedData.build.heritage}
+
+
+ Hintergrund:{' '}
+ {parsedData.build.background}
+
+
+
+ )}
+
+ {/* Actions */}
+
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/client/src/features/characters/index.ts b/client/src/features/characters/index.ts
new file mode 100644
index 0000000..9c2be21
--- /dev/null
+++ b/client/src/features/characters/index.ts
@@ -0,0 +1,3 @@
+export { CreateCharacterModal } from './components/create-character-modal';
+export { ImportCharacterModal } from './components/import-character-modal';
+export { CharacterSheetPage } from './components/character-sheet-page';
diff --git a/client/src/shared/components/layout.tsx b/client/src/shared/components/layout.tsx
index bc5e7a4..63153ff 100644
--- a/client/src/shared/components/layout.tsx
+++ b/client/src/shared/components/layout.tsx
@@ -1,6 +1,8 @@
import { Outlet, Link, useNavigate } from 'react-router-dom';
import { useAuthStore } from '@/features/auth';
import { Button } from './ui';
+import LogoIcon from '../../../Logos/Logo_ohne_Text.svg';
+import ZeasyLogo from '../../../Logos/Zeasy/logo_text_horizontal.svg';
export function Layout() {
const navigate = useNavigate();
@@ -18,12 +20,17 @@ export function Layout() {
{/* Logo */}
-
-
- D47
-
-
- Dimension47
+
+
+
+ Dimension 47
@@ -73,7 +80,17 @@ export function Layout() {
Powered by
-
Zeasy Software
+
+
+
diff --git a/client/src/shared/lib/api.ts b/client/src/shared/lib/api.ts
index 1a1a26a..3679de2 100644
--- a/client/src/shared/lib/api.ts
+++ b/client/src/shared/lib/api.ts
@@ -30,7 +30,11 @@ class ApiClient {
this.client.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
- if (error.response?.status === 401) {
+ // Don't redirect on 401 for auth endpoints (login, register)
+ const isAuthEndpoint = error.config?.url?.startsWith('/auth/login') ||
+ error.config?.url?.startsWith('/auth/register');
+
+ if (error.response?.status === 401 && !isAuthEndpoint) {
this.clearToken();
window.location.href = '/login';
}
@@ -115,14 +119,80 @@ class ApiClient {
return response.data;
}
- // Character endpoints (placeholder - to be expanded)
+ // Character endpoints
async getCharacters(campaignId: string) {
- const response = await this.client.get(`/characters/${campaignId}`);
+ const response = await this.client.get(`/campaigns/${campaignId}/characters`);
return response.data;
}
async getCharacter(campaignId: string, characterId: string) {
- const response = await this.client.get(`/characters/${campaignId}/${characterId}`);
+ const response = await this.client.get(`/campaigns/${campaignId}/characters/${characterId}`);
+ return response.data;
+ }
+
+ async createCharacter(campaignId: string, data: {
+ name: string;
+ type?: 'PC' | 'NPC';
+ level?: number;
+ avatarUrl?: string;
+ hpCurrent: number;
+ hpMax: number;
+ hpTemp?: number;
+ ancestryId?: string;
+ heritageId?: string;
+ classId?: string;
+ backgroundId?: string;
+ experiencePoints?: number;
+ pathbuilderData?: unknown;
+ }) {
+ const response = await this.client.post(`/campaigns/${campaignId}/characters`, data);
+ return response.data;
+ }
+
+ async importCharacterFromPathbuilder(campaignId: string, pathbuilderJson: unknown) {
+ const response = await this.client.post(`/campaigns/${campaignId}/characters/import`, {
+ pathbuilderJson,
+ });
+ return response.data;
+ }
+
+ async updateCharacter(campaignId: string, characterId: string, data: Partial<{
+ name: string;
+ type: 'PC' | 'NPC';
+ level: number;
+ avatarUrl: string;
+ hpCurrent: number;
+ hpMax: number;
+ hpTemp: number;
+ }>) {
+ const response = await this.client.put(`/campaigns/${campaignId}/characters/${characterId}`, data);
+ return response.data;
+ }
+
+ async deleteCharacter(campaignId: string, characterId: string) {
+ const response = await this.client.delete(`/campaigns/${campaignId}/characters/${characterId}`);
+ return response.data;
+ }
+
+ async updateCharacterHp(campaignId: string, characterId: string, hpCurrent: number, hpTemp?: number) {
+ const response = await this.client.patch(`/campaigns/${campaignId}/characters/${characterId}/hp`, { hpCurrent, hpTemp });
+ return response.data;
+ }
+
+ // Conditions
+ async addCharacterCondition(campaignId: string, characterId: string, data: {
+ name: string;
+ nameGerman?: string;
+ value?: number;
+ duration?: string;
+ source?: string;
+ }) {
+ const response = await this.client.post(`/campaigns/${campaignId}/characters/${characterId}/conditions`, data);
+ return response.data;
+ }
+
+ async removeCharacterCondition(campaignId: string, characterId: string, conditionId: string) {
+ const response = await this.client.delete(`/campaigns/${campaignId}/characters/${characterId}/conditions/${conditionId}`);
return response.data;
}
}
diff --git a/server/package-lock.json b/server/package-lock.json
index 8286508..2a5eb14 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -1,14 +1,15 @@
{
- "name": "server",
- "version": "0.0.1",
+ "name": "dimension47-server",
+ "version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "server",
- "version": "0.0.1",
+ "name": "dimension47-server",
+ "version": "1.0.0",
"license": "UNLICENSED",
"dependencies": {
+ "@anthropic-ai/sdk": "^0.71.2",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
@@ -18,6 +19,7 @@
"@nestjs/platform-socket.io": "^11.1.12",
"@nestjs/swagger": "^11.2.5",
"@nestjs/websockets": "^11.1.12",
+ "@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
@@ -205,6 +207,26 @@
"tslib": "^2.1.0"
}
},
+ "node_modules/@anthropic-ai/sdk": {
+ "version": "0.71.2",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz",
+ "integrity": "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==",
+ "license": "MIT",
+ "dependencies": {
+ "json-schema-to-ts": "^3.1.1"
+ },
+ "bin": {
+ "anthropic-ai-sdk": "bin/cli"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "zod": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
@@ -236,7 +258,6 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -667,6 +688,15 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
+ "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -809,8 +839,7 @@
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.2.tgz",
"integrity": "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==",
"devOptional": true,
- "license": "Apache-2.0",
- "peer": true
+ "license": "Apache-2.0"
},
"node_modules/@electric-sql/pglite-socket": {
"version": "0.0.6",
@@ -2250,7 +2279,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.12.tgz",
"integrity": "sha512-v6U3O01YohHO+IE3EIFXuRuu3VJILWzyMmSYZXpyBbnp0hk0mFyHxK2w3dF4I5WnbwiRbWlEXdeXFvPQ7qaZzw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"file-type": "21.3.0",
"iterare": "1.2.1",
@@ -2310,7 +2338,6 @@
"integrity": "sha512-97DzTYMf5RtGAVvX1cjwpKRiCUpkeQ9CCzSAenqkAhOmNVVFaApbhuw+xrDt13rsCa2hHVOYPrV4dBgOYMJjsA==",
"hasInstallScript": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@nuxt/opencollective": "0.4.1",
"fast-safe-stringify": "2.1.1",
@@ -2394,7 +2421,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.12.tgz",
"integrity": "sha512-GYK/vHI0SGz5m8mxr7v3Urx8b9t78Cf/dj5aJMZlGd9/1D9OI1hAl00BaphjEXINUJ/BQLxIlF2zUjrYsd6enQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"cors": "2.8.5",
"express": "5.2.1",
@@ -2416,7 +2442,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.12.tgz",
"integrity": "sha512-1itTTYsAZecrq2NbJOkch32y8buLwN7UpcNRdJrhlS+ovJ5GxLx3RyJ3KylwBhbYnO5AeYyL1U/i4W5mg/4qDA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"socket.io": "4.8.3",
"tslib": "2.8.1"
@@ -2595,7 +2620,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.12.tgz",
"integrity": "sha512-ulSOYcgosx1TqY425cRC5oXtAu1R10+OSmVfgyR9ueR25k4luekURt8dzAZxhxSCI0OsDj9WKCFLTkEuAwg0wg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"iterare": "1.2.1",
"object-hash": "3.0.0",
@@ -2677,6 +2701,17 @@
"url": "https://opencollective.com/pkgr"
}
},
+ "node_modules/@prisma/adapter-pg": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.2.0.tgz",
+ "integrity": "sha512-euIdQ13cRB2wZ3jPsnDnFhINquo1PYFPCg6yVL8b2rp3EdinQHsX9EDdCtRr489D5uhphcRk463OdQAFlsCr0w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/driver-adapter-utils": "7.2.0",
+ "pg": "^8.16.3",
+ "postgres-array": "3.0.4"
+ }
+ },
"node_modules/@prisma/client": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.2.0.tgz",
@@ -2724,7 +2759,6 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz",
"integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==",
- "devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/dev": {
@@ -2753,6 +2787,15 @@
"zeptomatch": "2.0.2"
}
},
+ "node_modules/@prisma/driver-adapter-utils": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.2.0.tgz",
+ "integrity": "sha512-gzrUcbI9VmHS24Uf+0+7DNzdIw7keglJsD5m/MHxQOU68OhGVzlphQRobLiDMn8CHNA2XN8uugwKjudVtnfMVQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "7.2.0"
+ }
+ },
"node_modules/@prisma/engines": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.2.0.tgz",
@@ -3049,7 +3092,6 @@
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/estree": "*",
"@types/json-schema": "*"
@@ -3188,7 +3230,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -3370,7 +3411,6 @@
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.53.0",
"@typescript-eslint/types": "8.53.0",
@@ -4052,7 +4092,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4102,7 +4141,6 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -4545,7 +4583,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4807,7 +4844,6 @@
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
@@ -4865,15 +4901,13 @@
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/class-validator": {
"version": "0.14.3",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz",
"integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/validator": "^13.15.3",
"libphonenumber-js": "^1.11.1",
@@ -5227,7 +5261,8 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/debug": {
"version": "4.4.3",
@@ -5691,7 +5726,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5752,7 +5786,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -5985,7 +6018,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -6723,7 +6755,6 @@
"integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -7110,7 +7141,6 @@
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@jest/core": "30.2.0",
"@jest/types": "30.2.0",
@@ -7904,6 +7934,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/json-schema-to-ts": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
+ "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.3",
+ "ts-algebra": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -8896,7 +8939,6 @@
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"passport-strategy": "1.x.x",
"pause": "0.0.1",
@@ -9024,6 +9066,104 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/pg": {
+ "version": "8.17.1",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.17.1.tgz",
+ "integrity": "sha512-EIR+jXdYNSMOrpRp7g6WgQr7SaZNZfS7IzZIO0oTNEeibq956JxeD15t3Jk3zZH0KH8DmOIx38qJfQenoE8bXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.10.0",
+ "pg-pool": "^3.11.0",
+ "pg-protocol": "^1.11.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.3.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
+ "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.10.0.tgz",
+ "integrity": "sha512-ur/eoPKzDx2IjPaYyXS6Y8NSblxM7X64deV2ObV57vhjsWiwLvUD6meukAzogiOsu60GO8m/3Cb6FdJsWNjwXg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.11.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
+ "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
+ "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pg-types/node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -9159,6 +9299,45 @@
"url": "https://github.com/sponsors/porsager"
}
},
+ "node_modules/postgres-array": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz",
+ "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -9175,7 +9354,6 @@
"integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -9234,7 +9412,6 @@
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"@prisma/config": "7.2.0",
"@prisma/dev": "0.17.0",
@@ -9445,8 +9622,7 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
- "license": "Apache-2.0",
- "peer": true
+ "license": "Apache-2.0"
},
"node_modules/regexp-to-ast": {
"version": "0.5.0",
@@ -9583,7 +9759,6 @@
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
@@ -9619,7 +9794,8 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"devOptional": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/schema-utils": {
"version": "3.3.0",
@@ -9952,6 +10128,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
@@ -10319,7 +10504,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -10551,6 +10735,12 @@
"url": "https://github.com/sponsors/Borewit"
}
},
+ "node_modules/ts-algebra": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
+ "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
+ "license": "MIT"
+ },
"node_modules/ts-api-utils": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
@@ -10657,7 +10847,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -10805,7 +10994,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -11087,7 +11275,6 @@
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@@ -11157,7 +11344,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
diff --git a/server/package.json b/server/package.json
index 0f0fc68..43c4258 100644
--- a/server/package.json
+++ b/server/package.json
@@ -23,6 +23,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
+ "@anthropic-ai/sdk": "^0.71.2",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
@@ -32,6 +33,7 @@
"@nestjs/platform-socket.io": "^11.1.12",
"@nestjs/swagger": "^11.2.5",
"@nestjs/websockets": "^11.1.12",
+ "@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
diff --git a/server/prisma/migrations/20260118162916_init/migration.sql b/server/prisma/migrations/20260118162916_init/migration.sql
new file mode 100644
index 0000000..aae8da3
--- /dev/null
+++ b/server/prisma/migrations/20260118162916_init/migration.sql
@@ -0,0 +1,543 @@
+-- CreateEnum
+CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'GM', 'PLAYER');
+
+-- CreateEnum
+CREATE TYPE "CharacterType" AS ENUM ('PC', 'NPC');
+
+-- CreateEnum
+CREATE TYPE "AbilityType" AS ENUM ('STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA');
+
+-- CreateEnum
+CREATE TYPE "Proficiency" AS ENUM ('UNTRAINED', 'TRAINED', 'EXPERT', 'MASTER', 'LEGENDARY');
+
+-- CreateEnum
+CREATE TYPE "FeatSource" AS ENUM ('CLASS', 'ANCESTRY', 'GENERAL', 'SKILL', 'BONUS', 'ARCHETYPE');
+
+-- CreateEnum
+CREATE TYPE "SpellTradition" AS ENUM ('ARCANE', 'DIVINE', 'OCCULT', 'PRIMAL');
+
+-- CreateEnum
+CREATE TYPE "CombatantType" AS ENUM ('PC', 'NPC', 'MONSTER');
+
+-- CreateEnum
+CREATE TYPE "ActionType" AS ENUM ('ACTION', 'REACTION', 'FREE');
+
+-- CreateEnum
+CREATE TYPE "HighlightColor" AS ENUM ('YELLOW', 'GREEN', 'BLUE', 'PINK');
+
+-- CreateEnum
+CREATE TYPE "TranslationType" AS ENUM ('FEAT', 'EQUIPMENT', 'SPELL', 'TRAIT', 'ANCESTRY', 'HERITAGE', 'CLASS', 'BACKGROUND', 'CONDITION', 'ACTION');
+
+-- CreateEnum
+CREATE TYPE "TranslationQuality" AS ENUM ('HIGH', 'MEDIUM', 'LOW');
+
+-- CreateTable
+CREATE TABLE "User" (
+ "id" TEXT NOT NULL,
+ "username" TEXT NOT NULL,
+ "email" TEXT NOT NULL,
+ "passwordHash" TEXT NOT NULL,
+ "role" "UserRole" NOT NULL DEFAULT 'PLAYER',
+ "avatarUrl" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "User_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Campaign" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "description" TEXT,
+ "gmId" TEXT NOT NULL,
+ "imageUrl" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Campaign_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "CampaignMember" (
+ "campaignId" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "CampaignMember_pkey" PRIMARY KEY ("campaignId","userId")
+);
+
+-- CreateTable
+CREATE TABLE "Character" (
+ "id" TEXT NOT NULL,
+ "campaignId" TEXT NOT NULL,
+ "ownerId" TEXT,
+ "name" TEXT NOT NULL,
+ "type" "CharacterType" NOT NULL DEFAULT 'PC',
+ "level" INTEGER NOT NULL DEFAULT 1,
+ "avatarUrl" TEXT,
+ "hpCurrent" INTEGER NOT NULL,
+ "hpMax" INTEGER NOT NULL,
+ "hpTemp" INTEGER NOT NULL DEFAULT 0,
+ "ancestryId" TEXT,
+ "heritageId" TEXT,
+ "classId" TEXT,
+ "backgroundId" TEXT,
+ "experiencePoints" INTEGER NOT NULL DEFAULT 0,
+ "pathbuilderData" JSONB,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Character_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "CharacterAbility" (
+ "id" TEXT NOT NULL,
+ "characterId" TEXT NOT NULL,
+ "ability" "AbilityType" NOT NULL,
+ "score" INTEGER NOT NULL,
+
+ CONSTRAINT "CharacterAbility_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "CharacterFeat" (
+ "id" TEXT NOT NULL,
+ "characterId" TEXT NOT NULL,
+ "featId" TEXT,
+ "name" TEXT NOT NULL,
+ "nameGerman" TEXT,
+ "level" INTEGER NOT NULL,
+ "source" "FeatSource" NOT NULL,
+
+ CONSTRAINT "CharacterFeat_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "CharacterSkill" (
+ "id" TEXT NOT NULL,
+ "characterId" TEXT NOT NULL,
+ "skillName" TEXT NOT NULL,
+ "proficiency" "Proficiency" NOT NULL DEFAULT 'UNTRAINED',
+
+ CONSTRAINT "CharacterSkill_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "CharacterSpell" (
+ "id" TEXT NOT NULL,
+ "characterId" TEXT NOT NULL,
+ "spellId" TEXT,
+ "name" TEXT NOT NULL,
+ "nameGerman" TEXT,
+ "tradition" "SpellTradition" NOT NULL,
+ "spellLevel" INTEGER NOT NULL,
+ "prepared" BOOLEAN NOT NULL DEFAULT false,
+
+ CONSTRAINT "CharacterSpell_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "CharacterItem" (
+ "id" TEXT NOT NULL,
+ "characterId" TEXT NOT NULL,
+ "equipmentId" TEXT,
+ "name" TEXT NOT NULL,
+ "nameGerman" TEXT,
+ "quantity" INTEGER NOT NULL DEFAULT 1,
+ "bulk" DECIMAL(65,30) NOT NULL DEFAULT 0,
+ "equipped" BOOLEAN NOT NULL DEFAULT false,
+ "invested" BOOLEAN NOT NULL DEFAULT false,
+ "containerId" TEXT,
+ "notes" TEXT,
+
+ CONSTRAINT "CharacterItem_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "CharacterCondition" (
+ "id" TEXT NOT NULL,
+ "characterId" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "nameGerman" TEXT,
+ "value" INTEGER,
+ "duration" TEXT,
+ "source" TEXT,
+
+ CONSTRAINT "CharacterCondition_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "CharacterResource" (
+ "id" TEXT NOT NULL,
+ "characterId" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "current" INTEGER NOT NULL,
+ "max" INTEGER NOT NULL,
+
+ CONSTRAINT "CharacterResource_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "BattleMap" (
+ "id" TEXT NOT NULL,
+ "campaignId" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "imageUrl" TEXT NOT NULL,
+ "gridSizeX" INTEGER NOT NULL DEFAULT 20,
+ "gridSizeY" INTEGER NOT NULL DEFAULT 20,
+ "gridOffsetX" INTEGER NOT NULL DEFAULT 0,
+ "gridOffsetY" INTEGER NOT NULL DEFAULT 0,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "BattleMap_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Combatant" (
+ "id" TEXT NOT NULL,
+ "campaignId" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "type" "CombatantType" NOT NULL,
+ "level" INTEGER NOT NULL,
+ "hpMax" INTEGER NOT NULL,
+ "ac" INTEGER NOT NULL,
+ "fortitude" INTEGER NOT NULL,
+ "reflex" INTEGER NOT NULL,
+ "will" INTEGER NOT NULL,
+ "perception" INTEGER NOT NULL,
+ "speed" INTEGER NOT NULL DEFAULT 25,
+ "avatarUrl" TEXT,
+ "description" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "Combatant_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "CombatantAbility" (
+ "id" TEXT NOT NULL,
+ "combatantId" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "actionCost" INTEGER NOT NULL,
+ "actionType" "ActionType" NOT NULL,
+ "description" TEXT NOT NULL,
+ "damage" TEXT,
+ "traits" TEXT[],
+
+ CONSTRAINT "CombatantAbility_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "BattleSession" (
+ "id" TEXT NOT NULL,
+ "campaignId" TEXT NOT NULL,
+ "mapId" TEXT,
+ "name" TEXT,
+ "isActive" BOOLEAN NOT NULL DEFAULT false,
+ "roundNumber" INTEGER NOT NULL DEFAULT 0,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "BattleSession_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "BattleToken" (
+ "id" TEXT NOT NULL,
+ "battleSessionId" TEXT NOT NULL,
+ "combatantId" TEXT,
+ "characterId" TEXT,
+ "name" TEXT NOT NULL,
+ "positionX" DOUBLE PRECISION NOT NULL,
+ "positionY" DOUBLE PRECISION NOT NULL,
+ "hpCurrent" INTEGER NOT NULL,
+ "hpMax" INTEGER NOT NULL,
+ "initiative" INTEGER,
+ "conditions" TEXT[],
+ "size" INTEGER NOT NULL DEFAULT 1,
+
+ CONSTRAINT "BattleToken_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Document" (
+ "id" TEXT NOT NULL,
+ "campaignId" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "description" TEXT,
+ "category" TEXT,
+ "tags" TEXT[],
+ "filePath" TEXT NOT NULL,
+ "fileType" TEXT NOT NULL,
+ "uploadedBy" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "Document_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "DocumentAccess" (
+ "id" TEXT NOT NULL,
+ "documentId" TEXT NOT NULL,
+ "userId" TEXT,
+ "characterId" TEXT,
+
+ CONSTRAINT "DocumentAccess_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Highlight" (
+ "id" TEXT NOT NULL,
+ "documentId" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "selectionText" TEXT NOT NULL,
+ "startOffset" INTEGER NOT NULL,
+ "endOffset" INTEGER NOT NULL,
+ "color" "HighlightColor" NOT NULL,
+ "note" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "Highlight_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Note" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "campaignId" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "content" TEXT NOT NULL,
+ "isShared" BOOLEAN NOT NULL DEFAULT false,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Note_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "NoteShare" (
+ "noteId" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+
+ CONSTRAINT "NoteShare_pkey" PRIMARY KEY ("noteId","userId")
+);
+
+-- CreateTable
+CREATE TABLE "Feat" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "traits" TEXT[],
+ "summary" TEXT,
+ "actions" TEXT,
+ "url" TEXT,
+ "level" INTEGER,
+ "sourceBook" TEXT,
+
+ CONSTRAINT "Feat_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Equipment" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "traits" TEXT[],
+ "itemCategory" TEXT NOT NULL,
+ "itemSubcategory" TEXT,
+ "bulk" TEXT,
+ "url" TEXT,
+ "summary" TEXT,
+ "activation" TEXT,
+ "hands" TEXT,
+ "damage" TEXT,
+ "range" TEXT,
+ "weaponCategory" TEXT,
+ "price" INTEGER,
+ "level" INTEGER,
+
+ CONSTRAINT "Equipment_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Spell" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "level" INTEGER NOT NULL,
+ "actions" TEXT,
+ "traditions" TEXT[],
+ "traits" TEXT[],
+ "range" TEXT,
+ "targets" TEXT,
+ "duration" TEXT,
+ "description" TEXT,
+ "url" TEXT,
+
+ CONSTRAINT "Spell_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Trait" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "description" TEXT,
+ "url" TEXT,
+
+ CONSTRAINT "Trait_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Translation" (
+ "id" TEXT NOT NULL,
+ "type" "TranslationType" NOT NULL,
+ "englishName" TEXT NOT NULL,
+ "germanName" TEXT NOT NULL,
+ "germanSummary" TEXT,
+ "germanDescription" TEXT,
+ "quality" "TranslationQuality" NOT NULL DEFAULT 'MEDIUM',
+ "translatedBy" TEXT NOT NULL DEFAULT 'claude-api',
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Translation_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "CharacterAbility_characterId_ability_key" ON "CharacterAbility"("characterId", "ability");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "CharacterSkill_characterId_skillName_key" ON "CharacterSkill"("characterId", "skillName");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "CharacterResource_characterId_name_key" ON "CharacterResource"("characterId", "name");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "DocumentAccess_documentId_userId_characterId_key" ON "DocumentAccess"("documentId", "userId", "characterId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Feat_name_key" ON "Feat"("name");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Equipment_name_key" ON "Equipment"("name");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Spell_name_key" ON "Spell"("name");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Trait_name_key" ON "Trait"("name");
+
+-- CreateIndex
+CREATE INDEX "Translation_type_idx" ON "Translation"("type");
+
+-- CreateIndex
+CREATE INDEX "Translation_englishName_idx" ON "Translation"("englishName");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Translation_type_englishName_key" ON "Translation"("type", "englishName");
+
+-- AddForeignKey
+ALTER TABLE "Campaign" ADD CONSTRAINT "Campaign_gmId_fkey" FOREIGN KEY ("gmId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CampaignMember" ADD CONSTRAINT "CampaignMember_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "Campaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CampaignMember" ADD CONSTRAINT "CampaignMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Character" ADD CONSTRAINT "Character_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "Campaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Character" ADD CONSTRAINT "Character_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CharacterAbility" ADD CONSTRAINT "CharacterAbility_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CharacterFeat" ADD CONSTRAINT "CharacterFeat_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CharacterFeat" ADD CONSTRAINT "CharacterFeat_featId_fkey" FOREIGN KEY ("featId") REFERENCES "Feat"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CharacterSkill" ADD CONSTRAINT "CharacterSkill_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CharacterSpell" ADD CONSTRAINT "CharacterSpell_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CharacterSpell" ADD CONSTRAINT "CharacterSpell_spellId_fkey" FOREIGN KEY ("spellId") REFERENCES "Spell"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CharacterItem" ADD CONSTRAINT "CharacterItem_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CharacterItem" ADD CONSTRAINT "CharacterItem_equipmentId_fkey" FOREIGN KEY ("equipmentId") REFERENCES "Equipment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CharacterCondition" ADD CONSTRAINT "CharacterCondition_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CharacterResource" ADD CONSTRAINT "CharacterResource_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "BattleMap" ADD CONSTRAINT "BattleMap_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "Campaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Combatant" ADD CONSTRAINT "Combatant_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "Campaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CombatantAbility" ADD CONSTRAINT "CombatantAbility_combatantId_fkey" FOREIGN KEY ("combatantId") REFERENCES "Combatant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "BattleSession" ADD CONSTRAINT "BattleSession_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "Campaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "BattleSession" ADD CONSTRAINT "BattleSession_mapId_fkey" FOREIGN KEY ("mapId") REFERENCES "BattleMap"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "BattleToken" ADD CONSTRAINT "BattleToken_battleSessionId_fkey" FOREIGN KEY ("battleSessionId") REFERENCES "BattleSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "BattleToken" ADD CONSTRAINT "BattleToken_combatantId_fkey" FOREIGN KEY ("combatantId") REFERENCES "Combatant"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "BattleToken" ADD CONSTRAINT "BattleToken_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Document" ADD CONSTRAINT "Document_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "Campaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Document" ADD CONSTRAINT "Document_uploadedBy_fkey" FOREIGN KEY ("uploadedBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "DocumentAccess" ADD CONSTRAINT "DocumentAccess_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "DocumentAccess" ADD CONSTRAINT "DocumentAccess_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "DocumentAccess" ADD CONSTRAINT "DocumentAccess_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Highlight" ADD CONSTRAINT "Highlight_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Highlight" ADD CONSTRAINT "Highlight_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Note" ADD CONSTRAINT "Note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Note" ADD CONSTRAINT "Note_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "Campaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "NoteShare" ADD CONSTRAINT "NoteShare_noteId_fkey" FOREIGN KEY ("noteId") REFERENCES "Note"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "NoteShare" ADD CONSTRAINT "NoteShare_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/server/prisma/migrations/migration_lock.toml b/server/prisma/migrations/migration_lock.toml
new file mode 100644
index 0000000..044d57c
--- /dev/null
+++ b/server/prisma/migrations/migration_lock.toml
@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (e.g., Git)
+provider = "postgresql"
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
index 7dc5a0e..9dcdff7 100644
--- a/server/prisma/schema.prisma
+++ b/server/prisma/schema.prisma
@@ -2,8 +2,9 @@
// Prisma Schema
generator client {
- provider = "prisma-client-js"
- output = "../src/generated/prisma"
+ provider = "prisma-client"
+ output = "../src/generated/prisma"
+ moduleFormat = "cjs"
}
datasource db {
diff --git a/server/prisma/seed.ts b/server/prisma/seed.ts
new file mode 100644
index 0000000..815d10a
--- /dev/null
+++ b/server/prisma/seed.ts
@@ -0,0 +1,28 @@
+import 'dotenv/config';
+import * as bcrypt from 'bcrypt';
+import { PrismaClient } from '../src/generated/prisma/client.js';
+import { PrismaPg } from '@prisma/adapter-pg';
+
+const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
+const prisma = new PrismaClient({ adapter });
+
+async function main() {
+ const passwordHash = await bcrypt.hash('admin123', 10);
+
+ const user = await prisma.user.upsert({
+ where: { email: 'admin@dimension47.local' },
+ update: {},
+ create: {
+ username: 'admin',
+ email: 'admin@dimension47.local',
+ passwordHash,
+ role: 'ADMIN',
+ },
+ });
+
+ console.log('Admin user created:', user.username, user.email);
+}
+
+main()
+ .catch(console.error)
+ .finally(() => prisma.$disconnect());
diff --git a/server/src/app.module.ts b/server/src/app.module.ts
index 1d24752..651b1e8 100644
--- a/server/src/app.module.ts
+++ b/server/src/app.module.ts
@@ -4,10 +4,13 @@ import { APP_GUARD } from '@nestjs/core';
// Core Modules
import { PrismaModule } from './prisma/prisma.module';
+import { ClaudeModule } from './modules/claude/claude.module';
// Feature Modules
import { AuthModule } from './modules/auth/auth.module';
import { CampaignsModule } from './modules/campaigns/campaigns.module';
+import { CharactersModule } from './modules/characters/characters.module';
+import { TranslationsModule } from './modules/translations/translations.module';
// Guards
import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
@@ -23,10 +26,13 @@ import { RolesGuard } from './modules/auth/guards/roles.guard';
// Core
PrismaModule,
+ ClaudeModule,
// Features
AuthModule,
CampaignsModule,
+ CharactersModule,
+ TranslationsModule,
],
providers: [
// Global JWT Auth Guard
diff --git a/server/src/common/decorators/roles.decorator.ts b/server/src/common/decorators/roles.decorator.ts
index 2b56874..f09a1dc 100644
--- a/server/src/common/decorators/roles.decorator.ts
+++ b/server/src/common/decorators/roles.decorator.ts
@@ -1,5 +1,5 @@
import { SetMetadata } from '@nestjs/common';
-import { UserRole } from '../../generated/prisma';
+import { UserRole } from '../../generated/prisma/client.js';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
diff --git a/server/src/main.ts b/server/src/main.ts
index 0f0869e..570dfcf 100644
--- a/server/src/main.ts
+++ b/server/src/main.ts
@@ -24,9 +24,25 @@ async function bootstrap() {
);
// CORS
- const corsOrigins = configService.get('CORS_ORIGINS', 'http://localhost:3000,http://localhost:5173');
+ const nodeEnv = configService.get('NODE_ENV', 'development');
app.enableCors({
- origin: corsOrigins.split(','),
+ origin: (origin, callback) => {
+ // Allow requests with no origin (mobile apps, curl, etc.)
+ if (!origin) return callback(null, true);
+
+ // In development, allow all localhost origins
+ if (nodeEnv === 'development' && /^https?:\/\/localhost(:\d+)?$/.test(origin)) {
+ return callback(null, true);
+ }
+
+ // Check against configured origins
+ const corsOrigins = configService.get('CORS_ORIGINS', 'http://localhost:3000,http://localhost:5173');
+ if (corsOrigins.split(',').includes(origin)) {
+ return callback(null, true);
+ }
+
+ callback(new Error('Not allowed by CORS'));
+ },
credentials: true,
});
diff --git a/server/src/modules/auth/auth.controller.ts b/server/src/modules/auth/auth.controller.ts
index 76bf795..aef608a 100644
--- a/server/src/modules/auth/auth.controller.ts
+++ b/server/src/modules/auth/auth.controller.ts
@@ -19,7 +19,7 @@ import { RegisterDto, LoginDto } from './dto';
import { Public } from '../../common/decorators/public.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
-import { UserRole } from '../../generated/prisma';
+import { UserRole } from '../../generated/prisma/client.js';
@ApiTags('Auth')
@Controller('auth')
diff --git a/server/src/modules/auth/auth.service.ts b/server/src/modules/auth/auth.service.ts
index f8a0fef..af72dc0 100644
--- a/server/src/modules/auth/auth.service.ts
+++ b/server/src/modules/auth/auth.service.ts
@@ -8,7 +8,7 @@ import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../../prisma/prisma.service';
import { RegisterDto, LoginDto } from './dto';
-import { UserRole } from '../../generated/prisma';
+import { UserRole } from '../../generated/prisma/client.js';
@Injectable()
export class AuthService {
diff --git a/server/src/modules/auth/guards/roles.guard.ts b/server/src/modules/auth/guards/roles.guard.ts
index fcb4323..95a8b9d 100644
--- a/server/src/modules/auth/guards/roles.guard.ts
+++ b/server/src/modules/auth/guards/roles.guard.ts
@@ -1,6 +1,6 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
-import { UserRole } from '../../../generated/prisma';
+import { UserRole } from '../../../generated/prisma/client.js';
import { ROLES_KEY } from '../../../common/decorators/roles.decorator';
@Injectable()
diff --git a/server/src/modules/campaigns/campaigns.controller.ts b/server/src/modules/campaigns/campaigns.controller.ts
index 724b678..920a73e 100644
--- a/server/src/modules/campaigns/campaigns.controller.ts
+++ b/server/src/modules/campaigns/campaigns.controller.ts
@@ -16,7 +16,7 @@ import {
import { CampaignsService } from './campaigns.service';
import { CreateCampaignDto, UpdateCampaignDto, AddMemberDto } from './dto';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
-import { UserRole } from '../../generated/prisma';
+import { UserRole } from '../../generated/prisma/client.js';
@ApiTags('Campaigns')
@ApiBearerAuth()
diff --git a/server/src/modules/campaigns/campaigns.service.ts b/server/src/modules/campaigns/campaigns.service.ts
index b610f77..ef1b169 100644
--- a/server/src/modules/campaigns/campaigns.service.ts
+++ b/server/src/modules/campaigns/campaigns.service.ts
@@ -6,7 +6,7 @@ import {
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateCampaignDto, UpdateCampaignDto, AddMemberDto } from './dto';
-import { UserRole } from '../../generated/prisma';
+import { UserRole } from '../../generated/prisma/client.js';
@Injectable()
export class CampaignsService {
diff --git a/server/src/modules/characters/characters.controller.ts b/server/src/modules/characters/characters.controller.ts
new file mode 100644
index 0000000..d8d1141
--- /dev/null
+++ b/server/src/modules/characters/characters.controller.ts
@@ -0,0 +1,292 @@
+import {
+ Controller,
+ Get,
+ Post,
+ Put,
+ Patch,
+ Delete,
+ Body,
+ Param,
+} from '@nestjs/common';
+import {
+ ApiTags,
+ ApiOperation,
+ ApiResponse,
+ ApiBearerAuth,
+} from '@nestjs/swagger';
+import { CharactersService } from './characters.service';
+import { PathbuilderImportService } from './pathbuilder-import.service';
+import {
+ CreateCharacterDto,
+ UpdateCharacterDto,
+ CreateAbilityDto,
+ CreateSkillDto,
+ CreateFeatDto,
+ CreateSpellDto,
+ CreateItemDto,
+ CreateConditionDto,
+ CreateResourceDto,
+ PathbuilderImportDto,
+} from './dto';
+import { CurrentUser } from '../../common/decorators/current-user.decorator';
+
+@ApiTags('Characters')
+@ApiBearerAuth()
+@Controller('campaigns/:campaignId/characters')
+export class CharactersController {
+ constructor(
+ private readonly charactersService: CharactersService,
+ private readonly pathbuilderImportService: PathbuilderImportService,
+ ) {}
+
+ @Post()
+ @ApiOperation({ summary: 'Create a new character' })
+ @ApiResponse({ status: 201, description: 'Character created successfully' })
+ async create(
+ @Param('campaignId') campaignId: string,
+ @Body() dto: CreateCharacterDto,
+ @CurrentUser('id') userId: string,
+ ) {
+ return this.charactersService.create(campaignId, dto, userId);
+ }
+
+ @Post('import')
+ @ApiOperation({ summary: 'Import character from Pathbuilder JSON' })
+ @ApiResponse({ status: 201, description: 'Character imported successfully' })
+ async importFromPathbuilder(
+ @Param('campaignId') campaignId: string,
+ @Body() dto: PathbuilderImportDto,
+ @CurrentUser('id') userId: string,
+ ) {
+ return this.pathbuilderImportService.importCharacter(
+ campaignId,
+ userId,
+ dto.pathbuilderJson,
+ );
+ }
+
+ @Get()
+ @ApiOperation({ summary: 'Get all characters in a campaign' })
+ @ApiResponse({ status: 200, description: 'List of characters' })
+ async findAll(
+ @Param('campaignId') campaignId: string,
+ @CurrentUser('id') userId: string,
+ ) {
+ return this.charactersService.findAllByCampaign(campaignId, userId);
+ }
+
+ @Get(':id')
+ @ApiOperation({ summary: 'Get character by ID' })
+ @ApiResponse({ status: 200, description: 'Character details' })
+ @ApiResponse({ status: 404, description: 'Character not found' })
+ async findOne(
+ @Param('id') id: string,
+ @CurrentUser('id') userId: string,
+ ) {
+ return this.charactersService.findOne(id, userId);
+ }
+
+ @Put(':id')
+ @ApiOperation({ summary: 'Update character' })
+ @ApiResponse({ status: 200, description: 'Character updated' })
+ async update(
+ @Param('id') id: string,
+ @Body() dto: UpdateCharacterDto,
+ @CurrentUser('id') userId: string,
+ ) {
+ return this.charactersService.update(id, dto, userId);
+ }
+
+ @Delete(':id')
+ @ApiOperation({ summary: 'Delete character' })
+ @ApiResponse({ status: 200, description: 'Character deleted' })
+ async remove(
+ @Param('id') id: string,
+ @CurrentUser('id') userId: string,
+ ) {
+ return this.charactersService.remove(id, userId);
+ }
+
+ // HP Management
+ @Patch(':id/hp')
+ @ApiOperation({ summary: 'Update character HP' })
+ async updateHp(
+ @Param('id') id: string,
+ @Body() body: { hpCurrent: number; hpTemp?: number },
+ @CurrentUser('id') userId: string,
+ ) {
+ return this.charactersService.updateHp(id, body.hpCurrent, body.hpTemp, userId);
+ }
+
+ // Abilities
+ @Put(':id/abilities')
+ @ApiOperation({ summary: 'Set character abilities' })
+ async setAbilities(
+ @Param('id') id: string,
+ @Body() abilities: CreateAbilityDto[],
+ @CurrentUser('id') userId: string,
+ ) {
+ return this.charactersService.setAbilities(id, abilities, userId);
+ }
+
+ // Skills
+ @Put(':id/skills')
+ @ApiOperation({ summary: 'Set character skills' })
+ async setSkills(
+ @Param('id') id: string,
+ @Body() skills: CreateSkillDto[],
+ @CurrentUser('id') userId: string,
+ ) {
+ return this.charactersService.setSkills(id, skills, userId);
+ }
+
+ @Patch(':id/skills/:skillName')
+ @ApiOperation({ summary: 'Update single skill proficiency' })
+ async updateSkill(
+ @Param('id') id: string,
+ @Param('skillName') skillName: string,
+ @Body() body: { proficiency: string },
+ @CurrentUser('id') userId: string,
+ ) {
+ return this.charactersService.updateSkill(id, skillName, body.proficiency, userId);
+ }
+
+ // Feats
+ @Post(':id/feats')
+ @ApiOperation({ summary: 'Add feat to character' })
+ async addFeat(
+ @Param('id') id: string,
+ @Body() dto: CreateFeatDto,
+ @CurrentUser('id') userId: string,
+ ) {
+ return this.charactersService.addFeat(id, dto, userId);
+ }
+
+ @Delete(':id/feats/:featId')
+ @ApiOperation({ summary: 'Remove feat from character' })
+ async removeFeat(
+ @Param('id') id: string,
+ @Param('featId') featId: string,
+ @CurrentUser('id') userId: string,
+ ) {
+ return this.charactersService.removeFeat(id, featId, userId);
+ }
+
+ // Spells
+ @Post(':id/spells')
+ @ApiOperation({ summary: 'Add spell to character' })
+ async addSpell(
+ @Param('id') id: string,
+ @Body() dto: CreateSpellDto,
+ @CurrentUser('id') userId: string,
+ ) {
+ return this.charactersService.addSpell(id, dto, userId);
+ }
+
+ @Patch(':id/spells/:spellId')
+ @ApiOperation({ summary: 'Update spell (prepared status)' })
+ async updateSpell(
+ @Param('id') id: string,
+ @Param('spellId') spellId: string,
+ @Body() body: { prepared: boolean },
+ @CurrentUser('id') userId: string,
+ ) {
+ return this.charactersService.updateSpell(id, spellId, body.prepared, userId);
+ }
+
+ @Delete(':id/spells/:spellId')
+ @ApiOperation({ summary: 'Remove spell from character' })
+ async removeSpell(
+ @Param('id') id: string,
+ @Param('spellId') spellId: string,
+ @CurrentUser('id') userId: string,
+ ) {
+ return this.charactersService.removeSpell(id, spellId, userId);
+ }
+
+ // Items
+ @Post(':id/items')
+ @ApiOperation({ summary: 'Add item to character' })
+ async addItem(
+ @Param('id') id: string,
+ @Body() dto: CreateItemDto,
+ @CurrentUser('id') userId: string,
+ ) {
+ return this.charactersService.addItem(id, dto, userId);
+ }
+
+ @Patch(':id/items/:itemId')
+ @ApiOperation({ summary: 'Update item' })
+ async updateItem(
+ @Param('id') id: string,
+ @Param('itemId') itemId: string,
+ @Body() body: Partial,
+ @CurrentUser('id') userId: string,
+ ) {
+ return this.charactersService.updateItem(id, itemId, body, userId);
+ }
+
+ @Delete(':id/items/:itemId')
+ @ApiOperation({ summary: 'Remove item from character' })
+ async removeItem(
+ @Param('id') id: string,
+ @Param('itemId') itemId: string,
+ @CurrentUser('id') userId: string,
+ ) {
+ return this.charactersService.removeItem(id, itemId, userId);
+ }
+
+ // Conditions
+ @Post(':id/conditions')
+ @ApiOperation({ summary: 'Add condition to character' })
+ async addCondition(
+ @Param('id') id: string,
+ @Body() dto: CreateConditionDto,
+ @CurrentUser('id') userId: string,
+ ) {
+ return this.charactersService.addCondition(id, dto, userId);
+ }
+
+ @Patch(':id/conditions/:conditionId')
+ @ApiOperation({ summary: 'Update condition value' })
+ async updateCondition(
+ @Param('id') id: string,
+ @Param('conditionId') conditionId: string,
+ @Body() body: { value: number },
+ @CurrentUser('id') userId: string,
+ ) {
+ return this.charactersService.updateCondition(id, conditionId, body.value, userId);
+ }
+
+ @Delete(':id/conditions/:conditionId')
+ @ApiOperation({ summary: 'Remove condition from character' })
+ async removeCondition(
+ @Param('id') id: string,
+ @Param('conditionId') conditionId: string,
+ @CurrentUser('id') userId: string,
+ ) {
+ return this.charactersService.removeCondition(id, conditionId, userId);
+ }
+
+ // Resources
+ @Put(':id/resources')
+ @ApiOperation({ summary: 'Set character resources' })
+ async setResources(
+ @Param('id') id: string,
+ @Body() resources: CreateResourceDto[],
+ @CurrentUser('id') userId: string,
+ ) {
+ return this.charactersService.setResources(id, resources, userId);
+ }
+
+ @Patch(':id/resources/:resourceName')
+ @ApiOperation({ summary: 'Update resource current value' })
+ async updateResource(
+ @Param('id') id: string,
+ @Param('resourceName') resourceName: string,
+ @Body() body: { current: number },
+ @CurrentUser('id') userId: string,
+ ) {
+ return this.charactersService.updateResource(id, resourceName, body.current, userId);
+ }
+}
diff --git a/server/src/modules/characters/characters.module.ts b/server/src/modules/characters/characters.module.ts
new file mode 100644
index 0000000..d763930
--- /dev/null
+++ b/server/src/modules/characters/characters.module.ts
@@ -0,0 +1,13 @@
+import { Module } from '@nestjs/common';
+import { CharactersController } from './characters.controller';
+import { CharactersService } from './characters.service';
+import { PathbuilderImportService } from './pathbuilder-import.service';
+import { TranslationsModule } from '../translations/translations.module';
+
+@Module({
+ imports: [TranslationsModule],
+ controllers: [CharactersController],
+ providers: [CharactersService, PathbuilderImportService],
+ exports: [CharactersService, PathbuilderImportService],
+})
+export class CharactersModule {}
diff --git a/server/src/modules/characters/characters.service.ts b/server/src/modules/characters/characters.service.ts
new file mode 100644
index 0000000..48faad6
--- /dev/null
+++ b/server/src/modules/characters/characters.service.ts
@@ -0,0 +1,311 @@
+import {
+ Injectable,
+ NotFoundException,
+ ForbiddenException,
+} from '@nestjs/common';
+import { PrismaService } from '../../prisma/prisma.service';
+import {
+ CreateCharacterDto,
+ UpdateCharacterDto,
+ CreateAbilityDto,
+ CreateSkillDto,
+ CreateFeatDto,
+ CreateSpellDto,
+ CreateItemDto,
+ CreateConditionDto,
+ CreateResourceDto,
+} from './dto';
+
+@Injectable()
+export class CharactersService {
+ constructor(private prisma: PrismaService) {}
+
+ // Check if user has access to campaign
+ private async checkCampaignAccess(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 hasAccess =
+ campaign.gmId === userId ||
+ campaign.members.some((m) => m.userId === userId);
+
+ if (!hasAccess) {
+ throw new ForbiddenException('No access to this campaign');
+ }
+
+ return campaign;
+ }
+
+ // Check if user can edit character
+ private async checkCharacterAccess(characterId: string, userId: string, requireOwnership = false) {
+ const character = await this.prisma.character.findUnique({
+ where: { id: characterId },
+ include: { campaign: { include: { members: true } } },
+ });
+
+ if (!character) {
+ throw new NotFoundException('Character not found');
+ }
+
+ const isGM = character.campaign.gmId === userId;
+ const isOwner = character.ownerId === userId;
+
+ if (requireOwnership && !isOwner && !isGM) {
+ throw new ForbiddenException('Only the owner or GM can modify this character');
+ }
+
+ const isMember = character.campaign.members.some((m) => m.userId === userId);
+ if (!isGM && !isMember) {
+ throw new ForbiddenException('No access to this character');
+ }
+
+ return character;
+ }
+
+ async create(campaignId: string, dto: CreateCharacterDto, userId: string) {
+ await this.checkCampaignAccess(campaignId, userId);
+
+ return this.prisma.character.create({
+ data: {
+ ...dto,
+ campaignId,
+ ownerId: userId,
+ pathbuilderData: dto.pathbuilderData as any,
+ },
+ include: {
+ owner: { select: { id: true, username: true } },
+ abilities: true,
+ skills: true,
+ feats: true,
+ spells: true,
+ items: true,
+ conditions: true,
+ resources: true,
+ },
+ });
+ }
+
+ async findAllByCampaign(campaignId: string, userId: string) {
+ await this.checkCampaignAccess(campaignId, userId);
+
+ return this.prisma.character.findMany({
+ where: { campaignId },
+ include: {
+ owner: { select: { id: true, username: true } },
+ },
+ orderBy: { name: 'asc' },
+ });
+ }
+
+ async findOne(id: string, userId: string) {
+ const character = await this.checkCharacterAccess(id, userId);
+
+ return this.prisma.character.findUnique({
+ where: { id },
+ include: {
+ owner: { select: { id: true, username: true, avatarUrl: true } },
+ abilities: true,
+ skills: { orderBy: { skillName: 'asc' } },
+ feats: { orderBy: [{ level: 'asc' }, { name: 'asc' }] },
+ spells: { orderBy: [{ spellLevel: 'asc' }, { name: 'asc' }] },
+ items: { orderBy: { name: 'asc' } },
+ conditions: true,
+ resources: true,
+ },
+ });
+ }
+
+ async update(id: string, dto: UpdateCharacterDto, userId: string) {
+ await this.checkCharacterAccess(id, userId, true);
+
+ return this.prisma.character.update({
+ where: { id },
+ data: {
+ ...dto,
+ pathbuilderData: dto.pathbuilderData as any,
+ },
+ include: {
+ owner: { select: { id: true, username: true } },
+ abilities: true,
+ skills: true,
+ feats: true,
+ spells: true,
+ items: true,
+ conditions: true,
+ resources: true,
+ },
+ });
+ }
+
+ async remove(id: string, userId: string) {
+ await this.checkCharacterAccess(id, userId, true);
+
+ await this.prisma.character.delete({ where: { id } });
+ return { message: 'Character deleted successfully' };
+ }
+
+ // HP Management
+ async updateHp(id: string, hpCurrent: number, hpTemp?: number, userId?: string) {
+ if (userId) {
+ await this.checkCharacterAccess(id, userId, true);
+ }
+
+ return this.prisma.character.update({
+ where: { id },
+ data: {
+ hpCurrent: Math.max(0, hpCurrent),
+ ...(hpTemp !== undefined && { hpTemp: Math.max(0, hpTemp) }),
+ },
+ });
+ }
+
+ // Abilities
+ async setAbilities(characterId: string, abilities: CreateAbilityDto[], userId: string) {
+ await this.checkCharacterAccess(characterId, userId, true);
+
+ // Delete existing and create new
+ await this.prisma.characterAbility.deleteMany({ where: { characterId } });
+
+ return this.prisma.characterAbility.createMany({
+ data: abilities.map((a) => ({ ...a, characterId })),
+ });
+ }
+
+ // Skills
+ async setSkills(characterId: string, skills: CreateSkillDto[], userId: string) {
+ await this.checkCharacterAccess(characterId, userId, true);
+
+ await this.prisma.characterSkill.deleteMany({ where: { characterId } });
+
+ return this.prisma.characterSkill.createMany({
+ data: skills.map((s) => ({ ...s, characterId })),
+ });
+ }
+
+ async updateSkill(characterId: string, skillName: string, proficiency: string, userId: string) {
+ await this.checkCharacterAccess(characterId, userId, true);
+
+ return this.prisma.characterSkill.upsert({
+ where: { characterId_skillName: { characterId, skillName } },
+ update: { proficiency: proficiency as any },
+ create: { characterId, skillName, proficiency: proficiency as any },
+ });
+ }
+
+ // Feats
+ async addFeat(characterId: string, dto: CreateFeatDto, userId: string) {
+ await this.checkCharacterAccess(characterId, userId, true);
+
+ return this.prisma.characterFeat.create({
+ data: { ...dto, characterId },
+ });
+ }
+
+ async removeFeat(characterId: string, featId: string, userId: string) {
+ await this.checkCharacterAccess(characterId, userId, true);
+
+ await this.prisma.characterFeat.delete({ where: { id: featId } });
+ return { message: 'Feat removed' };
+ }
+
+ // Spells
+ async addSpell(characterId: string, dto: CreateSpellDto, userId: string) {
+ await this.checkCharacterAccess(characterId, userId, true);
+
+ return this.prisma.characterSpell.create({
+ data: { ...dto, characterId },
+ });
+ }
+
+ async updateSpell(characterId: string, spellId: string, prepared: boolean, userId: string) {
+ await this.checkCharacterAccess(characterId, userId, true);
+
+ return this.prisma.characterSpell.update({
+ where: { id: spellId },
+ data: { prepared },
+ });
+ }
+
+ async removeSpell(characterId: string, spellId: string, userId: string) {
+ await this.checkCharacterAccess(characterId, userId, true);
+
+ await this.prisma.characterSpell.delete({ where: { id: spellId } });
+ return { message: 'Spell removed' };
+ }
+
+ // Items
+ async addItem(characterId: string, dto: CreateItemDto, userId: string) {
+ await this.checkCharacterAccess(characterId, userId, true);
+
+ return this.prisma.characterItem.create({
+ data: { ...dto, characterId, bulk: dto.bulk as any },
+ });
+ }
+
+ async updateItem(characterId: string, itemId: string, data: Partial, userId: string) {
+ await this.checkCharacterAccess(characterId, userId, true);
+
+ return this.prisma.characterItem.update({
+ where: { id: itemId },
+ data: { ...data, bulk: data.bulk as any },
+ });
+ }
+
+ async removeItem(characterId: string, itemId: string, userId: string) {
+ await this.checkCharacterAccess(characterId, userId, true);
+
+ await this.prisma.characterItem.delete({ where: { id: itemId } });
+ return { message: 'Item removed' };
+ }
+
+ // Conditions
+ async addCondition(characterId: string, dto: CreateConditionDto, userId: string) {
+ await this.checkCharacterAccess(characterId, userId, true);
+
+ return this.prisma.characterCondition.create({
+ data: { ...dto, characterId },
+ });
+ }
+
+ async updateCondition(characterId: string, conditionId: string, value: number, userId: string) {
+ await this.checkCharacterAccess(characterId, userId, true);
+
+ return this.prisma.characterCondition.update({
+ where: { id: conditionId },
+ data: { value },
+ });
+ }
+
+ async removeCondition(characterId: string, conditionId: string, userId: string) {
+ await this.checkCharacterAccess(characterId, userId, true);
+
+ await this.prisma.characterCondition.delete({ where: { id: conditionId } });
+ return { message: 'Condition removed' };
+ }
+
+ // Resources
+ async setResources(characterId: string, resources: CreateResourceDto[], userId: string) {
+ await this.checkCharacterAccess(characterId, userId, true);
+
+ await this.prisma.characterResource.deleteMany({ where: { characterId } });
+
+ return this.prisma.characterResource.createMany({
+ data: resources.map((r) => ({ ...r, characterId })),
+ });
+ }
+
+ async updateResource(characterId: string, resourceName: string, current: number, userId: string) {
+ await this.checkCharacterAccess(characterId, userId, true);
+
+ return this.prisma.characterResource.update({
+ where: { characterId_name: { characterId, name: resourceName } },
+ data: { current: Math.max(0, current) },
+ });
+ }
+}
diff --git a/server/src/modules/characters/dto/create-character.dto.ts b/server/src/modules/characters/dto/create-character.dto.ts
new file mode 100644
index 0000000..0c7a745
--- /dev/null
+++ b/server/src/modules/characters/dto/create-character.dto.ts
@@ -0,0 +1,236 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { IsString, IsOptional, IsEnum, IsInt, Min, Max, IsArray, ValidateNested } from 'class-validator';
+import { Type } from 'class-transformer';
+import { CharacterType, AbilityType, Proficiency, FeatSource, SpellTradition } from '../../../generated/prisma/client.js';
+
+export class CreateCharacterDto {
+ @ApiProperty({ description: 'Character name' })
+ @IsString()
+ name: string;
+
+ @ApiPropertyOptional({ enum: ['PC', 'NPC'], default: 'PC' })
+ @IsOptional()
+ @IsEnum(CharacterType)
+ type?: CharacterType = CharacterType.PC;
+
+ @ApiPropertyOptional({ default: 1 })
+ @IsOptional()
+ @IsInt()
+ @Min(1)
+ @Max(20)
+ level?: number = 1;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ avatarUrl?: string;
+
+ @ApiProperty({ description: 'Current HP' })
+ @IsInt()
+ @Min(0)
+ hpCurrent: number;
+
+ @ApiProperty({ description: 'Maximum HP' })
+ @IsInt()
+ @Min(1)
+ hpMax: number;
+
+ @ApiPropertyOptional({ default: 0 })
+ @IsOptional()
+ @IsInt()
+ @Min(0)
+ hpTemp?: number = 0;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ ancestryId?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ heritageId?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ classId?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ backgroundId?: string;
+
+ @ApiPropertyOptional({ default: 0 })
+ @IsOptional()
+ @IsInt()
+ @Min(0)
+ experiencePoints?: number = 0;
+
+ @ApiPropertyOptional({ description: 'Raw Pathbuilder JSON data' })
+ @IsOptional()
+ pathbuilderData?: unknown;
+}
+
+export class CreateAbilityDto {
+ @ApiProperty({ enum: ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'] })
+ @IsEnum(AbilityType)
+ ability: AbilityType;
+
+ @ApiProperty()
+ @IsInt()
+ @Min(1)
+ @Max(30)
+ score: number;
+}
+
+export class CreateSkillDto {
+ @ApiProperty()
+ @IsString()
+ skillName: string;
+
+ @ApiPropertyOptional({ enum: ['UNTRAINED', 'TRAINED', 'EXPERT', 'MASTER', 'LEGENDARY'], default: 'UNTRAINED' })
+ @IsOptional()
+ @IsEnum(Proficiency)
+ proficiency?: Proficiency = Proficiency.UNTRAINED;
+}
+
+export class CreateFeatDto {
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ featId?: string;
+
+ @ApiProperty()
+ @IsString()
+ name: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ nameGerman?: string;
+
+ @ApiProperty()
+ @IsInt()
+ level: number;
+
+ @ApiProperty({ enum: ['CLASS', 'ANCESTRY', 'GENERAL', 'SKILL', 'BONUS', 'ARCHETYPE'] })
+ @IsEnum(FeatSource)
+ source: FeatSource;
+}
+
+export class CreateSpellDto {
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ spellId?: string;
+
+ @ApiProperty()
+ @IsString()
+ name: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ nameGerman?: string;
+
+ @ApiProperty({ enum: ['ARCANE', 'DIVINE', 'OCCULT', 'PRIMAL'] })
+ @IsEnum(SpellTradition)
+ tradition: SpellTradition;
+
+ @ApiProperty()
+ @IsInt()
+ @Min(0)
+ @Max(10)
+ spellLevel: number;
+
+ @ApiPropertyOptional({ default: false })
+ @IsOptional()
+ prepared?: boolean = false;
+}
+
+export class CreateItemDto {
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ equipmentId?: string;
+
+ @ApiProperty()
+ @IsString()
+ name: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ nameGerman?: string;
+
+ @ApiPropertyOptional({ default: 1 })
+ @IsOptional()
+ @IsInt()
+ @Min(1)
+ quantity?: number = 1;
+
+ @ApiPropertyOptional({ default: 0 })
+ @IsOptional()
+ bulk?: number = 0;
+
+ @ApiPropertyOptional({ default: false })
+ @IsOptional()
+ equipped?: boolean = false;
+
+ @ApiPropertyOptional({ default: false })
+ @IsOptional()
+ invested?: boolean = false;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ containerId?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ notes?: string;
+}
+
+export class CreateConditionDto {
+ @ApiProperty()
+ @IsString()
+ name: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ nameGerman?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsInt()
+ value?: number;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ duration?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ source?: string;
+}
+
+export class CreateResourceDto {
+ @ApiProperty()
+ @IsString()
+ name: string;
+
+ @ApiProperty()
+ @IsInt()
+ @Min(0)
+ current: number;
+
+ @ApiProperty()
+ @IsInt()
+ @Min(1)
+ max: number;
+}
diff --git a/server/src/modules/characters/dto/index.ts b/server/src/modules/characters/dto/index.ts
new file mode 100644
index 0000000..d742f5e
--- /dev/null
+++ b/server/src/modules/characters/dto/index.ts
@@ -0,0 +1,3 @@
+export * from './create-character.dto';
+export * from './update-character.dto';
+export * from './pathbuilder-import.dto';
diff --git a/server/src/modules/characters/dto/pathbuilder-import.dto.ts b/server/src/modules/characters/dto/pathbuilder-import.dto.ts
new file mode 100644
index 0000000..033a4ae
--- /dev/null
+++ b/server/src/modules/characters/dto/pathbuilder-import.dto.ts
@@ -0,0 +1,152 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsObject, ValidateNested } from 'class-validator';
+import { Type } from 'class-transformer';
+
+// Pathbuilder JSON structure
+export interface PathbuilderBuild {
+ name: string;
+ class: string;
+ dualClass?: string | null;
+ level: number;
+ xp: number;
+ ancestry: string;
+ heritage: string;
+ background: string;
+ alignment: string;
+ gender: string;
+ age: string;
+ deity: string;
+ size: number;
+ sizeName: string;
+ keyability: string;
+ languages: string[];
+ abilities: {
+ str: number;
+ dex: number;
+ con: number;
+ int: number;
+ wis: number;
+ cha: number;
+ breakdown?: unknown;
+ };
+ attributes: {
+ ancestryhp: number;
+ classhp: number;
+ bonushp: number;
+ bonushpPerLevel: number;
+ speed: number;
+ speedBonus: number;
+ };
+ proficiencies: {
+ classDC: number;
+ perception: number;
+ fortitude: number;
+ reflex: number;
+ will: number;
+ heavy: number;
+ medium: number;
+ light: number;
+ unarmored: number;
+ advanced: number;
+ martial: number;
+ simple: number;
+ unarmed: number;
+ castingArcane: number;
+ castingDivine: number;
+ castingOccult: number;
+ castingPrimal: number;
+ acrobatics: number;
+ arcana: number;
+ athletics: number;
+ crafting: number;
+ deception: number;
+ diplomacy: number;
+ intimidation: number;
+ medicine: number;
+ nature: number;
+ occultism: number;
+ performance: number;
+ religion: number;
+ society: number;
+ stealth: number;
+ survival: number;
+ thievery: number;
+ };
+ feats: Array<[string, unknown, string, number, ...unknown[]]>;
+ specials: string[];
+ lores: Array<[string, number]>;
+ equipment: Array<[string, number, string?]>;
+ weapons: Array<{
+ name: string;
+ qty: number;
+ prof: string;
+ die: string;
+ pot: number;
+ str: string;
+ mat: string | null;
+ display: string;
+ runes: string[];
+ damageType: string;
+ attack: number;
+ damageBonus: number;
+ extraDamage: string[];
+ increasedDice: boolean;
+ isInventor: boolean;
+ }>;
+ armor: Array<{
+ name: string;
+ qty: number;
+ prof: string;
+ pot: number;
+ res: string;
+ mat: string | null;
+ display: string;
+ worn: boolean;
+ runes: string[];
+ }>;
+ money: {
+ cp: number;
+ sp: number;
+ gp: number;
+ pp: number;
+ };
+ spellCasters: Array<{
+ name: string;
+ magicTradition: string;
+ spellcastingType: string;
+ ability: string;
+ proficiency: number;
+ focusPoints: number;
+ spells: Array<{
+ spellLevel: number;
+ list: string[];
+ }>;
+ perDay: number[];
+ }>;
+ focusPoints: number;
+ focus: unknown;
+ formula: Array<{
+ type: string;
+ known: string[];
+ }>;
+ acTotal: {
+ acProfBonus: number;
+ acAbilityBonus: number;
+ acItemBonus: number;
+ acTotal: number;
+ shieldBonus: number | null;
+ };
+ pets: unknown[];
+ familiars: unknown[];
+}
+
+export interface PathbuilderJson {
+ success: boolean;
+ build: PathbuilderBuild;
+}
+
+export class PathbuilderImportDto {
+ @ApiProperty({ description: 'Raw Pathbuilder export JSON' })
+ @IsObject()
+ pathbuilderJson: PathbuilderJson;
+}
diff --git a/server/src/modules/characters/dto/update-character.dto.ts b/server/src/modules/characters/dto/update-character.dto.ts
new file mode 100644
index 0000000..f8cb148
--- /dev/null
+++ b/server/src/modules/characters/dto/update-character.dto.ts
@@ -0,0 +1,4 @@
+import { PartialType } from '@nestjs/swagger';
+import { CreateCharacterDto } from './create-character.dto';
+
+export class UpdateCharacterDto extends PartialType(CreateCharacterDto) {}
diff --git a/server/src/modules/characters/pathbuilder-import.service.ts b/server/src/modules/characters/pathbuilder-import.service.ts
new file mode 100644
index 0000000..e15ea31
--- /dev/null
+++ b/server/src/modules/characters/pathbuilder-import.service.ts
@@ -0,0 +1,407 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { PrismaService } from '../../prisma/prisma.service';
+import { TranslationsService } from '../translations/translations.service';
+import {
+ AbilityType,
+ Proficiency,
+ FeatSource,
+ TranslationType,
+ CharacterType,
+} from '../../generated/prisma/client.js';
+import { PathbuilderJson, PathbuilderBuild } from './dto/pathbuilder-import.dto';
+
+// Skill name mappings (English -> German)
+const SKILL_TRANSLATIONS: Record = {
+ acrobatics: 'Akrobatik',
+ arcana: 'Arkane Künste',
+ athletics: 'Athletik',
+ crafting: 'Handwerk',
+ deception: 'Täuschung',
+ diplomacy: 'Diplomatie',
+ intimidation: 'Einschüchtern',
+ medicine: 'Medizin',
+ nature: 'Naturkunde',
+ occultism: 'Okkultismus',
+ performance: 'Darbietung',
+ religion: 'Religionskunde',
+ society: 'Gesellschaftskunde',
+ stealth: 'Heimlichkeit',
+ survival: 'Überleben',
+ thievery: 'Diebeskunst',
+};
+
+// Proficiency value mappings (Pathbuilder uses 0, 2, 4, 6, 8)
+function proficiencyFromValue(value: number): Proficiency {
+ switch (value) {
+ case 8: return Proficiency.LEGENDARY;
+ case 6: return Proficiency.MASTER;
+ case 4: return Proficiency.EXPERT;
+ case 2: return Proficiency.TRAINED;
+ default: return Proficiency.UNTRAINED;
+ }
+}
+
+// Feat source mapping
+function featSourceFromType(type: string): FeatSource {
+ const normalized = type.toLowerCase();
+ if (normalized.includes('class')) return FeatSource.CLASS;
+ if (normalized.includes('ancestry') || normalized.includes('human')) return FeatSource.ANCESTRY;
+ if (normalized.includes('skill')) return FeatSource.SKILL;
+ if (normalized.includes('general')) return FeatSource.GENERAL;
+ if (normalized.includes('archetype')) return FeatSource.ARCHETYPE;
+ return FeatSource.BONUS;
+}
+
+@Injectable()
+export class PathbuilderImportService {
+ private readonly logger = new Logger(PathbuilderImportService.name);
+
+ constructor(
+ private prisma: PrismaService,
+ private translationsService: TranslationsService,
+ ) {}
+
+ /**
+ * Import a character from Pathbuilder JSON
+ */
+ async importCharacter(
+ campaignId: string,
+ ownerId: string,
+ pathbuilderJson: PathbuilderJson,
+ ) {
+ const build = pathbuilderJson.build;
+ this.logger.log(`Importing character: ${build.name} (Level ${build.level} ${build.class})`);
+
+ // Calculate HP
+ const conModifier = Math.floor((build.abilities.con - 10) / 2);
+ const hpMax = build.attributes.ancestryhp +
+ build.attributes.classhp +
+ build.attributes.bonushp +
+ (conModifier * build.level) +
+ (build.attributes.bonushpPerLevel * build.level);
+
+ // Translate basics
+ const [classTranslation, ancestryTranslation, heritageTranslation, backgroundTranslation] =
+ await Promise.all([
+ this.translationsService.getTranslation(TranslationType.CLASS, build.class),
+ this.translationsService.getTranslation(TranslationType.ANCESTRY, build.ancestry),
+ this.translationsService.getTranslation(TranslationType.HERITAGE, build.heritage),
+ this.translationsService.getTranslation(TranslationType.BACKGROUND, build.background),
+ ]);
+
+ // Create character
+ const character = await this.prisma.character.create({
+ data: {
+ campaignId,
+ ownerId,
+ name: build.name,
+ type: CharacterType.PC,
+ level: build.level,
+ hpCurrent: hpMax,
+ hpMax,
+ hpTemp: 0,
+ ancestryId: build.ancestry,
+ heritageId: build.heritage,
+ classId: build.class,
+ backgroundId: build.background,
+ experiencePoints: build.xp || 0,
+ pathbuilderData: JSON.parse(JSON.stringify({
+ ...pathbuilderJson,
+ translations: {
+ class: classTranslation,
+ ancestry: ancestryTranslation,
+ heritage: heritageTranslation,
+ background: backgroundTranslation,
+ },
+ })),
+ },
+ });
+
+ this.logger.log(`Character created with ID: ${character.id}`);
+
+ // Import all related data in parallel
+ await Promise.all([
+ this.importAbilities(character.id, build),
+ this.importSkills(character.id, build),
+ this.importFeats(character.id, build),
+ this.importItems(character.id, build),
+ this.importResources(character.id, build),
+ ]);
+
+ // Fetch complete character with relations
+ return this.prisma.character.findUnique({
+ where: { id: character.id },
+ include: {
+ owner: { select: { id: true, username: true } },
+ abilities: true,
+ skills: { orderBy: { skillName: 'asc' } },
+ feats: { orderBy: [{ level: 'asc' }, { name: 'asc' }] },
+ items: { orderBy: { name: 'asc' } },
+ resources: true,
+ conditions: true,
+ },
+ });
+ }
+
+ /**
+ * Import ability scores
+ */
+ private async importAbilities(characterId: string, build: PathbuilderBuild) {
+ const abilities = [
+ { ability: AbilityType.STR, score: build.abilities.str },
+ { ability: AbilityType.DEX, score: build.abilities.dex },
+ { ability: AbilityType.CON, score: build.abilities.con },
+ { ability: AbilityType.INT, score: build.abilities.int },
+ { ability: AbilityType.WIS, score: build.abilities.wis },
+ { ability: AbilityType.CHA, score: build.abilities.cha },
+ ];
+
+ await this.prisma.characterAbility.createMany({
+ data: abilities.map(a => ({ ...a, characterId })),
+ });
+
+ this.logger.debug(`Imported ${abilities.length} abilities`);
+ }
+
+ /**
+ * Import skills with proficiencies
+ */
+ private async importSkills(characterId: string, build: PathbuilderBuild) {
+ const prof = build.proficiencies;
+ const skills = [
+ { skillName: 'Acrobatics', skillNameGerman: SKILL_TRANSLATIONS.acrobatics, proficiency: proficiencyFromValue(prof.acrobatics) },
+ { skillName: 'Arcana', skillNameGerman: SKILL_TRANSLATIONS.arcana, proficiency: proficiencyFromValue(prof.arcana) },
+ { skillName: 'Athletics', skillNameGerman: SKILL_TRANSLATIONS.athletics, proficiency: proficiencyFromValue(prof.athletics) },
+ { skillName: 'Crafting', skillNameGerman: SKILL_TRANSLATIONS.crafting, proficiency: proficiencyFromValue(prof.crafting) },
+ { skillName: 'Deception', skillNameGerman: SKILL_TRANSLATIONS.deception, proficiency: proficiencyFromValue(prof.deception) },
+ { skillName: 'Diplomacy', skillNameGerman: SKILL_TRANSLATIONS.diplomacy, proficiency: proficiencyFromValue(prof.diplomacy) },
+ { skillName: 'Intimidation', skillNameGerman: SKILL_TRANSLATIONS.intimidation, proficiency: proficiencyFromValue(prof.intimidation) },
+ { skillName: 'Medicine', skillNameGerman: SKILL_TRANSLATIONS.medicine, proficiency: proficiencyFromValue(prof.medicine) },
+ { skillName: 'Nature', skillNameGerman: SKILL_TRANSLATIONS.nature, proficiency: proficiencyFromValue(prof.nature) },
+ { skillName: 'Occultism', skillNameGerman: SKILL_TRANSLATIONS.occultism, proficiency: proficiencyFromValue(prof.occultism) },
+ { skillName: 'Performance', skillNameGerman: SKILL_TRANSLATIONS.performance, proficiency: proficiencyFromValue(prof.performance) },
+ { skillName: 'Religion', skillNameGerman: SKILL_TRANSLATIONS.religion, proficiency: proficiencyFromValue(prof.religion) },
+ { skillName: 'Society', skillNameGerman: SKILL_TRANSLATIONS.society, proficiency: proficiencyFromValue(prof.society) },
+ { skillName: 'Stealth', skillNameGerman: SKILL_TRANSLATIONS.stealth, proficiency: proficiencyFromValue(prof.stealth) },
+ { skillName: 'Survival', skillNameGerman: SKILL_TRANSLATIONS.survival, proficiency: proficiencyFromValue(prof.survival) },
+ { skillName: 'Thievery', skillNameGerman: SKILL_TRANSLATIONS.thievery, proficiency: proficiencyFromValue(prof.thievery) },
+ // Saves
+ { skillName: 'Perception', skillNameGerman: 'Wahrnehmung', proficiency: proficiencyFromValue(prof.perception) },
+ { skillName: 'Fortitude', skillNameGerman: 'Zähigkeit', proficiency: proficiencyFromValue(prof.fortitude) },
+ { skillName: 'Reflex', skillNameGerman: 'Reflex', proficiency: proficiencyFromValue(prof.reflex) },
+ { skillName: 'Will', skillNameGerman: 'Wille', proficiency: proficiencyFromValue(prof.will) },
+ ];
+
+ // Add lore skills
+ for (const [loreName, profValue] of build.lores) {
+ skills.push({
+ skillName: `Lore: ${loreName}`,
+ skillNameGerman: `Wissen: ${loreName}`,
+ proficiency: proficiencyFromValue(profValue),
+ });
+ }
+
+ await this.prisma.characterSkill.createMany({
+ data: skills.map(s => ({
+ characterId,
+ skillName: s.skillName,
+ proficiency: s.proficiency,
+ })),
+ });
+
+ this.logger.debug(`Imported ${skills.length} skills`);
+ }
+
+ /**
+ * Import feats with translations
+ */
+ private async importFeats(characterId: string, build: PathbuilderBuild) {
+ if (!build.feats || build.feats.length === 0) {
+ return;
+ }
+
+ // Extract feat names for batch translation
+ const featNames = build.feats.map(f => ({ englishName: f[0] as string }));
+ const translations = await this.translationsService.getTranslationsBatch(
+ TranslationType.FEAT,
+ featNames,
+ );
+
+ const feats = build.feats.map(feat => {
+ const name = feat[0] as string;
+ const featType = feat[2] as string;
+ const level = feat[3] as number;
+ const translation = translations.get(name);
+
+ return {
+ characterId,
+ name,
+ nameGerman: translation?.germanName || name,
+ level,
+ source: featSourceFromType(featType),
+ };
+ });
+
+ await this.prisma.characterFeat.createMany({ data: feats });
+ this.logger.debug(`Imported ${feats.length} feats`);
+ }
+
+ /**
+ * Import items (weapons, armor, equipment)
+ */
+ private async importItems(characterId: string, build: PathbuilderBuild) {
+ const items: Array<{
+ characterId: string;
+ name: string;
+ nameGerman?: string;
+ quantity: number;
+ bulk: number;
+ equipped: boolean;
+ invested: boolean;
+ notes?: string;
+ }> = [];
+
+ // Collect all item names for batch translation
+ const itemNames: Array<{ englishName: string }> = [];
+
+ // Weapons
+ for (const weapon of build.weapons || []) {
+ itemNames.push({ englishName: weapon.name });
+ items.push({
+ characterId,
+ name: weapon.name,
+ quantity: weapon.qty || 1,
+ bulk: 1, // Default bulk for weapons
+ equipped: true,
+ invested: false,
+ notes: `${weapon.die} ${weapon.damageType}${weapon.extraDamage?.length ? ' + ' + weapon.extraDamage.join(', ') : ''}`,
+ });
+ }
+
+ // Armor
+ for (const armor of build.armor || []) {
+ itemNames.push({ englishName: armor.name });
+ items.push({
+ characterId,
+ name: armor.name,
+ quantity: armor.qty || 1,
+ bulk: 1, // Default bulk for armor
+ equipped: armor.worn,
+ invested: false,
+ });
+ }
+
+ // General equipment
+ for (const equip of build.equipment || []) {
+ const name = equip[0] as string;
+ const qty = equip[1] as number;
+ const invested = equip[2] === 'Invested';
+
+ itemNames.push({ englishName: name });
+ items.push({
+ characterId,
+ name,
+ quantity: qty,
+ bulk: 0,
+ equipped: false,
+ invested,
+ });
+ }
+
+ // Add money as a note item
+ if (build.money) {
+ const moneyNote: string[] = [];
+ if (build.money.pp > 0) moneyNote.push(`${build.money.pp} PP`);
+ if (build.money.gp > 0) moneyNote.push(`${build.money.gp} GP`);
+ if (build.money.sp > 0) moneyNote.push(`${build.money.sp} SP`);
+ if (build.money.cp > 0) moneyNote.push(`${build.money.cp} CP`);
+
+ if (moneyNote.length > 0) {
+ items.push({
+ characterId,
+ name: 'Münzbeutel',
+ quantity: 1,
+ bulk: 0,
+ equipped: false,
+ invested: false,
+ notes: moneyNote.join(', '),
+ });
+ }
+ }
+
+ // Batch translate items
+ if (itemNames.length > 0) {
+ const translations = await this.translationsService.getTranslationsBatch(
+ TranslationType.EQUIPMENT,
+ itemNames,
+ );
+
+ // Apply translations
+ for (const item of items) {
+ if (item.name !== 'Münzbeutel') {
+ const translation = translations.get(item.name);
+ if (translation) {
+ item.nameGerman = translation.germanName;
+ }
+ }
+ }
+ }
+
+ if (items.length > 0) {
+ await this.prisma.characterItem.createMany({
+ data: items.map(i => ({
+ ...i,
+ bulk: i.bulk,
+ })),
+ });
+ this.logger.debug(`Imported ${items.length} items`);
+ }
+ }
+
+ /**
+ * Import resources (focus points, spell slots, etc.)
+ */
+ private async importResources(characterId: string, build: PathbuilderBuild) {
+ const resources: Array<{ characterId: string; name: string; current: number; max: number }> = [];
+
+ // Focus Points
+ if (build.focusPoints > 0) {
+ resources.push({
+ characterId,
+ name: 'Fokuspunkte',
+ current: build.focusPoints,
+ max: build.focusPoints,
+ });
+ }
+
+ // Spell slots from spellcasters
+ for (const caster of build.spellCasters || []) {
+ if (caster.perDay) {
+ for (let level = 0; level < caster.perDay.length; level++) {
+ const slots = caster.perDay[level];
+ if (slots > 0) {
+ resources.push({
+ characterId,
+ name: level === 0 ? 'Zaubertricks' : `Zauberplätze Grad ${level}`,
+ current: slots,
+ max: slots,
+ });
+ }
+ }
+ }
+ }
+
+ // Hero Points (always starts at 1)
+ resources.push({
+ characterId,
+ name: 'Heldenpunkte',
+ current: 1,
+ max: 3,
+ });
+
+ if (resources.length > 0) {
+ await this.prisma.characterResource.createMany({ data: resources });
+ this.logger.debug(`Imported ${resources.length} resources`);
+ }
+ }
+}
diff --git a/server/src/modules/claude/claude.module.ts b/server/src/modules/claude/claude.module.ts
new file mode 100644
index 0000000..0e504b8
--- /dev/null
+++ b/server/src/modules/claude/claude.module.ts
@@ -0,0 +1,9 @@
+import { Module, Global } from '@nestjs/common';
+import { ClaudeService } from './claude.service';
+
+@Global()
+@Module({
+ providers: [ClaudeService],
+ exports: [ClaudeService],
+})
+export class ClaudeModule {}
diff --git a/server/src/modules/claude/claude.service.ts b/server/src/modules/claude/claude.service.ts
new file mode 100644
index 0000000..9e002aa
--- /dev/null
+++ b/server/src/modules/claude/claude.service.ts
@@ -0,0 +1,138 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import Anthropic from '@anthropic-ai/sdk';
+
+export interface TranslationRequest {
+ type: 'FEAT' | 'ITEM' | 'SPELL' | 'ACTION' | 'SKILL' | 'CLASS' | 'ANCESTRY' | 'HERITAGE' | 'BACKGROUND' | 'CONDITION' | 'TRAIT';
+ englishName: string;
+ englishDescription?: string;
+ context?: string;
+}
+
+export interface TranslationResponse {
+ englishName: string;
+ germanName: string;
+ germanDescription?: string;
+ translationQuality: 'HIGH' | 'MEDIUM' | 'LOW';
+}
+
+@Injectable()
+export class ClaudeService {
+ private readonly logger = new Logger(ClaudeService.name);
+ private client: Anthropic;
+
+ constructor(private configService: ConfigService) {
+ const apiKey = this.configService.get('ANTHROPIC_API_KEY');
+ if (apiKey) {
+ this.client = new Anthropic({ apiKey });
+ this.logger.log('Claude API initialized');
+ } else {
+ this.logger.warn('ANTHROPIC_API_KEY not set - translations will be disabled');
+ }
+ }
+
+ async translateBatch(items: TranslationRequest[]): Promise {
+ if (!this.client) {
+ this.logger.warn('Claude API not available, returning untranslated items');
+ return items.map(item => ({
+ englishName: item.englishName,
+ germanName: item.englishName,
+ germanDescription: item.englishDescription,
+ translationQuality: 'LOW' as const,
+ }));
+ }
+
+ if (items.length === 0) {
+ return [];
+ }
+
+ try {
+ const itemsList = items.map((item, i) =>
+ `${i + 1}. Type: ${item.type}, Name: "${item.englishName}"${item.englishDescription ? `, Description: "${item.englishDescription}"` : ''}${item.context ? `, Context: ${item.context}` : ''}`
+ ).join('\n');
+
+ const prompt = `Du bist ein Übersetzer für Pathfinder 2e Spielinhalte von Englisch nach Deutsch.
+
+WICHTIGE ÜBERSETZUNGSREGELN:
+- "Feat" = "Talent"
+- "Action" = "Aktion"
+- "Spell" = "Zauber"
+- "Weapon" = "Waffe"
+- "Armor" = "Rüstung"
+- "Shield" = "Schild"
+- "Item" = "Gegenstand"
+- "Skill" = "Fertigkeit"
+- "Class" = "Klasse"
+- "Ancestry" = "Abstammung"
+- "Heritage" = "Erbe"
+- "Background" = "Hintergrund"
+- "Condition" = "Zustand"
+- "Trait" = "Merkmal"
+
+Behalte Pathfinder-spezifische Begriffe bei (z.B. "Versatile", "Finesse" bleiben auf Englisch als Spielmechanik-Begriffe).
+Übersetze Eigennamen nicht (z.B. "Alchemist's Fire" → "Alchemistenfeuer", aber "Bane" bleibt "Bane").
+
+Übersetze folgende ${items.length} Einträge:
+
+${itemsList}
+
+Antworte NUR mit einem JSON-Array in diesem Format:
+[
+ {
+ "englishName": "Original English Name",
+ "germanName": "Deutscher Name",
+ "germanDescription": "Deutsche Beschreibung (falls vorhanden)",
+ "confidence": 0.9
+ }
+]
+
+Gib confidence zwischen 0.0 und 1.0 an basierend auf der Übersetzungsqualität.`;
+
+ const response = await this.client.messages.create({
+ model: 'claude-3-5-sonnet-20241022',
+ max_tokens: 4000,
+ messages: [{ role: 'user', content: prompt }],
+ });
+
+ const content = response.content[0];
+ if (content.type !== 'text') {
+ throw new Error('Unexpected response type from Claude');
+ }
+
+ // Extract JSON from response
+ const jsonMatch = content.text.match(/\[[\s\S]*\]/);
+ if (!jsonMatch) {
+ this.logger.error('Could not extract JSON from Claude response');
+ throw new Error('Invalid JSON response from Claude');
+ }
+
+ const translations = JSON.parse(jsonMatch[0]) as Array<{
+ englishName: string;
+ germanName: string;
+ germanDescription?: string;
+ confidence: number;
+ }>;
+
+ return translations.map(t => ({
+ englishName: t.englishName,
+ germanName: t.germanName,
+ germanDescription: t.germanDescription,
+ translationQuality: t.confidence >= 0.8 ? 'HIGH' : t.confidence >= 0.6 ? 'MEDIUM' : 'LOW',
+ }));
+ } catch (error) {
+ this.logger.error('Claude translation failed:', error);
+ // Return untranslated items as fallback
+ return items.map(item => ({
+ englishName: item.englishName,
+ germanName: item.englishName,
+ germanDescription: item.englishDescription,
+ translationQuality: 'LOW' as const,
+ }));
+ }
+ }
+
+ async translateSingle(item: TranslationRequest): Promise {
+ const results = await this.translateBatch([item]);
+ return results[0];
+ }
+}
diff --git a/server/src/modules/claude/index.ts b/server/src/modules/claude/index.ts
new file mode 100644
index 0000000..aa1b3a4
--- /dev/null
+++ b/server/src/modules/claude/index.ts
@@ -0,0 +1,2 @@
+export * from './claude.service';
+export * from './claude.module';
diff --git a/server/src/modules/translations/index.ts b/server/src/modules/translations/index.ts
new file mode 100644
index 0000000..419bbd1
--- /dev/null
+++ b/server/src/modules/translations/index.ts
@@ -0,0 +1,3 @@
+export * from './translations.service';
+export * from './translations.controller';
+export * from './translations.module';
diff --git a/server/src/modules/translations/translations.controller.ts b/server/src/modules/translations/translations.controller.ts
new file mode 100644
index 0000000..be35eee
--- /dev/null
+++ b/server/src/modules/translations/translations.controller.ts
@@ -0,0 +1,57 @@
+import {
+ Controller,
+ Get,
+ Post,
+ Body,
+ Param,
+ UseGuards,
+} from '@nestjs/common';
+import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
+import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
+import { TranslationsService } from './translations.service';
+import { TranslationType } from '../../generated/prisma/client.js';
+
+class TranslateRequestDto {
+ type: TranslationType;
+ englishName: string;
+ englishDescription?: string;
+}
+
+class TranslateBatchRequestDto {
+ type: TranslationType;
+ items: Array<{ englishName: string; englishDescription?: string }>;
+}
+
+@ApiTags('translations')
+@ApiBearerAuth()
+@UseGuards(JwtAuthGuard)
+@Controller('translations')
+export class TranslationsController {
+ constructor(private translationsService: TranslationsService) {}
+
+ @Post()
+ @ApiOperation({ summary: 'Translate a single item' })
+ async translate(@Body() dto: TranslateRequestDto) {
+ return this.translationsService.getTranslation(
+ dto.type,
+ dto.englishName,
+ dto.englishDescription,
+ );
+ }
+
+ @Post('batch')
+ @ApiOperation({ summary: 'Translate multiple items' })
+ async translateBatch(@Body() dto: TranslateBatchRequestDto) {
+ const result = await this.translationsService.getTranslationsBatch(
+ dto.type,
+ dto.items,
+ );
+ return Object.fromEntries(result);
+ }
+
+ @Get(':type')
+ @ApiOperation({ summary: 'Get all cached translations of a type' })
+ async getAllByType(@Param('type') type: TranslationType) {
+ return this.translationsService.getAllByType(type);
+ }
+}
diff --git a/server/src/modules/translations/translations.module.ts b/server/src/modules/translations/translations.module.ts
new file mode 100644
index 0000000..2c175a0
--- /dev/null
+++ b/server/src/modules/translations/translations.module.ts
@@ -0,0 +1,10 @@
+import { Module } from '@nestjs/common';
+import { TranslationsService } from './translations.service';
+import { TranslationsController } from './translations.controller';
+
+@Module({
+ controllers: [TranslationsController],
+ providers: [TranslationsService],
+ exports: [TranslationsService],
+})
+export class TranslationsModule {}
diff --git a/server/src/modules/translations/translations.service.ts b/server/src/modules/translations/translations.service.ts
new file mode 100644
index 0000000..248592c
--- /dev/null
+++ b/server/src/modules/translations/translations.service.ts
@@ -0,0 +1,159 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { PrismaService } from '../../prisma/prisma.service';
+import { ClaudeService, TranslationRequest, TranslationResponse } from '../claude/claude.service';
+import { TranslationType, TranslationQuality } from '../../generated/prisma/client.js';
+
+@Injectable()
+export class TranslationsService {
+ private readonly logger = new Logger(TranslationsService.name);
+
+ constructor(
+ private prisma: PrismaService,
+ private claudeService: ClaudeService,
+ ) {}
+
+ /**
+ * Get a translation from cache or translate via Claude
+ */
+ async getTranslation(
+ type: TranslationType,
+ englishName: string,
+ englishDescription?: string,
+ ): Promise {
+ // Check cache first
+ const cached = await this.prisma.translation.findUnique({
+ where: { type_englishName: { type, englishName } },
+ });
+
+ if (cached && !this.isIncomplete(cached.germanDescription)) {
+ this.logger.debug(`Cache hit for ${type}: ${englishName}`);
+ return {
+ englishName: cached.englishName,
+ germanName: cached.germanName,
+ germanDescription: cached.germanDescription || undefined,
+ translationQuality: cached.quality,
+ };
+ }
+
+ // Translate via Claude
+ this.logger.debug(`Cache miss for ${type}: ${englishName}, calling Claude`);
+ const translation = await this.claudeService.translateSingle({
+ type: type as TranslationRequest['type'],
+ englishName,
+ englishDescription,
+ });
+
+ // Cache the result
+ await this.upsertTranslation(type, translation);
+
+ return translation;
+ }
+
+ /**
+ * Get multiple translations, using cache where possible
+ */
+ async getTranslationsBatch(
+ type: TranslationType,
+ items: Array<{ englishName: string; englishDescription?: string }>,
+ ): Promise