feat: Implement PF2e Alchemy and Rest system

Alchemy System:
- Versatile Vials tracking with refill functionality
- Research Field display (Bomber, Chirurgeon, Mutagenist, Toxicologist)
- Formula Book with search and level filtering
- Advanced Alchemy (daily preparation) for infused items
- Quick Alchemy using versatile vials
- Normal Alchemy for permanent crafted items
- Auto-upgrade system for formula variants (Lesser → Greater)
- Effect parsing with damage badges (damage type colors, splash, healing, bonus)
- German translations for all UI elements and item effects
- WebSocket sync for all alchemy state changes

Rest System:
- HP healing based on CON modifier × Level
- Condition management (Fatigued removed, Doomed/Drained reduced)
- Resource reset (spell slots, focus points, daily abilities)
- Alchemy reset (infused items expire, vials refilled)
- Rest modal with preview of changes

Database:
- CharacterAlchemyState model for vials and batch tracking
- CharacterFormula model for formula book
- CharacterPreparedItem model with isInfused flag
- Equipment effect field for variant-specific effects
- Translation germanEffect field for effect translations
- Scraped effect data from Archives of Nethys (205 items)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alexander Zielonka
2026-01-20 15:24:40 +01:00
parent 55419d3896
commit 618de7b21d
29 changed files with 5543 additions and 227 deletions

View File

@@ -151,10 +151,29 @@ dimension47/
- **Deutsch**: Alle UI-Texte auf Deutsch
- **Keine Emojis**: Nur Lucide Icons
## Environment-Variablen
### Server (`server/.env`)
```bash
PORT=5000 # Server-Port
DATABASE_URL="postgresql://..." # PostgreSQL Connection String
JWT_SECRET="..." # JWT Signing Key
ANTHROPIC_API_KEY="..." # Claude API für Übersetzungen
```
### Client (`client/.env`)
```bash
VITE_API_URL=http://localhost:5000/api # Muss mit Server PORT übereinstimmen
```
**Wichtig**: Die WebSocket-URL wird automatisch aus `VITE_API_URL` abgeleitet (ohne `/api` Suffix).
Beispiel-Dateien: `server/.env.example` und `client/.env.example`
## Entwicklung
```bash
# Backend starten (Port 3001)
# Backend starten (Port 5000, konfigurierbar via PORT in .env)
cd server && npm run start:dev
# Frontend starten (Port 5173)

6
client/.env.example Normal file
View File

@@ -0,0 +1,6 @@
# Dimension47 Client Environment Variables
# API Configuration
# The base URL for the backend API (must match server PORT)
# WebSocket URL is derived automatically by removing /api suffix
VITE_API_URL=http://localhost:5000/api

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,7 @@ import {
User,
Star,
Coins,
Moon,
} from 'lucide-react';
import {
Button,
@@ -34,8 +35,10 @@ import { EditCharacterModal } from './edit-character-modal';
import { AddFeatModal } from './add-feat-modal';
import { FeatDetailModal } from './feat-detail-modal';
import { ActionsTab } from './actions-tab';
import { RestModal } from './rest-modal';
import { AlchemyTab } from './alchemy-tab';
import { useCharacterSocket } from '@/shared/hooks/use-character-socket';
import type { Character, CharacterItem, CharacterFeat, Campaign } from '@/shared/types';
import type { Character, CharacterItem, CharacterFeat, Campaign, RestResult, CharacterAlchemyState, CharacterFormula, CharacterPreparedItem } from '@/shared/types';
type TabType = 'status' | 'skills' | 'inventory' | 'feats' | 'spells' | 'alchemy' | 'actions';
@@ -129,6 +132,7 @@ export function CharacterSheetPage() {
const [selectedFeat, setSelectedFeat] = useState<CharacterFeat | null>(null);
const [editingCredits, setEditingCredits] = useState(false);
const [creditsInput, setCreditsInput] = useState('');
const [showRestModal, setShowRestModal] = useState(false);
const isOwner = character?.ownerId === user?.id;
const isGM = campaign?.gmId === user?.id;
@@ -211,6 +215,99 @@ export function CharacterSheetPage() {
onLevelUpdate: (data) => {
setCharacter((prev) => prev ? { ...prev, level: data.level } : null);
},
onRestUpdate: (data) => {
setCharacter((prev) => {
if (!prev) return null;
// Update HP
let updated = { ...prev, hpCurrent: data.hpCurrent };
// Remove conditions
if (data.conditionsRemoved.length > 0) {
updated.conditions = updated.conditions.filter(
(c) => !data.conditionsRemoved.includes(c.name) && !data.conditionsRemoved.includes(c.nameGerman || '')
);
}
// Update reduced conditions
if (data.conditionsReduced.length > 0) {
updated.conditions = updated.conditions.map((c) => {
const reduced = data.conditionsReduced.find(
(r) => r.name === c.name || r.name === c.nameGerman
);
if (reduced) {
return { ...c, value: reduced.newValue };
}
return c;
});
}
// Reset resources
if (data.resourcesReset.length > 0) {
updated.resources = updated.resources.map((r) => {
if (data.resourcesReset.includes(r.name)) {
return { ...r, current: r.max };
}
return r;
});
}
// Reset alchemy
if (data.alchemyReset) {
// Always clear prepared items when alchemy is reset
updated.preparedItems = [];
// Update alchemy state if it exists
if (updated.alchemyState) {
updated.alchemyState = {
...updated.alchemyState,
versatileVialsCurrent: updated.alchemyState.versatileVialsMax,
advancedAlchemyBatch: 0,
};
}
}
return updated;
});
},
onAlchemyVialsUpdate: (data) => {
setCharacter((prev) => {
if (!prev || !prev.alchemyState) return prev;
return {
...prev,
alchemyState: { ...prev.alchemyState, versatileVialsCurrent: data.versatileVialsCurrent },
};
});
},
onAlchemyFormulasUpdate: (data) => {
setCharacter((prev) => {
if (!prev) return prev;
if (data.action === 'add' && data.formula) {
return { ...prev, formulas: [...(prev.formulas || []), data.formula] };
}
if (data.action === 'remove' && data.formulaId) {
return { ...prev, formulas: (prev.formulas || []).filter((f) => f.id !== data.formulaId) };
}
return prev;
});
},
onAlchemyPreparedUpdate: (data) => {
setCharacter((prev) => {
if (!prev) return prev;
if (data.action === 'prepare' && data.items) {
return { ...prev, preparedItems: [...(prev.preparedItems || []), ...data.items] };
}
if (data.action === 'quick_alchemy' && data.item) {
return { ...prev, preparedItems: [data.item, ...(prev.preparedItems || [])] };
}
if (data.action === 'update' && data.item) {
return {
...prev,
preparedItems: (prev.preparedItems || []).map((i) => i.id === data.item!.id ? data.item! : i),
};
}
if (data.action === 'remove' && data.itemId) {
return { ...prev, preparedItems: (prev.preparedItems || []).filter((i) => i.id !== data.itemId) };
}
return prev;
});
},
onAlchemyStateUpdate: (data) => {
setCharacter((prev) => prev ? { ...prev, alchemyState: data } : prev);
},
onFullUpdate: (updatedCharacter) => {
setCharacter(updatedCharacter);
},
@@ -680,6 +777,16 @@ export function CharacterSheetPage() {
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
{/* Rest Button */}
<Button
variant="outline"
className="w-full mt-4 border-indigo-500/30 text-indigo-400 hover:bg-indigo-500/10 hover:text-indigo-300"
onClick={() => setShowRestModal(true)}
>
<Moon className="h-4 w-4 mr-2" />
Rasten (8 Stunden)
</Button>
</div>
);
};
@@ -1323,69 +1430,24 @@ export function CharacterSheetPage() {
</div>
);
const renderAlchemyTab = () => (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FlaskConical className="h-5 w-5" />
Alchemie-Ressourcen
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{character.resources.filter(r => r.name.toLowerCase().includes('vial') || r.name.toLowerCase().includes('alchemy')).length === 0 ? (
<p className="text-center text-text-secondary py-4">
Keine Alchemie-Ressourcen verfügbar
</p>
) : (
character.resources.filter(r => r.name.toLowerCase().includes('vial') || r.name.toLowerCase().includes('alchemy')).map((resource) => (
<div key={resource.id} className="flex items-center justify-between p-3 rounded-lg bg-bg-secondary">
<span className="font-medium text-text-primary">{resource.name}</span>
<div className="flex items-center gap-2">
<Button size="icon" variant="ghost" className="h-8 w-8">
<Minus className="h-4 w-4" />
</Button>
<span className="font-bold text-text-primary min-w-[60px] text-center">
{resource.current} / {resource.max}
</span>
<Button size="icon" variant="ghost" className="h-8 w-8">
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
</CardContent>
</Card>
{/* Alchemical Items from Inventory */}
<Card>
<CardHeader>
<CardTitle>Alchemistische Gegenstände</CardTitle>
</CardHeader>
<CardContent>
{character.items.filter(i => i.name.toLowerCase().includes('bomb') || i.name.toLowerCase().includes('elixir') || i.name.toLowerCase().includes('mutagen')).length === 0 ? (
<p className="text-center text-text-secondary py-4">
Keine alchemistischen Gegenstände
</p>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{character.items.filter(i => i.name.toLowerCase().includes('bomb') || i.name.toLowerCase().includes('elixir') || i.name.toLowerCase().includes('mutagen')).map((item) => (
<div key={item.id} className="p-3 rounded-lg bg-bg-secondary">
<span className="font-medium text-text-primary">
{item.nameGerman || item.name}
{item.quantity > 1 && ` (×${item.quantity})`}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
const renderAlchemyTab = () => {
if (!campaignId) return null;
return (
<AlchemyTab
character={character}
campaignId={campaignId}
onStateUpdate={(state) => {
setCharacter((prev) => prev ? { ...prev, alchemyState: state } : prev);
}}
onFormulasUpdate={(formulas) => {
setCharacter((prev) => prev ? { ...prev, formulas } : prev);
}}
onPreparedItemsUpdate={(items) => {
setCharacter((prev) => prev ? { ...prev, preparedItems: items } : prev);
}}
/>
);
};
const renderActionsTab = () => {
return <ActionsTab characterFeats={character.feats} />;
@@ -1527,6 +1589,17 @@ export function CharacterSheetPage() {
onRemove={() => handleRemoveFeat(selectedFeat.id)}
/>
)}
{showRestModal && campaignId && (
<RestModal
campaignId={campaignId}
characterId={character.id}
onClose={() => setShowRestModal(false)}
onRestComplete={(result) => {
// The WebSocket will handle the state update
console.log('Rest complete:', result);
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,199 @@
import { useState, useEffect } from 'react';
import { Moon, Heart, Shield, Sparkles, FlaskConical, X, Loader2 } from 'lucide-react';
import { Button, Card, CardContent } from '@/shared/components/ui';
import { api } from '@/shared/lib/api';
import type { RestPreview, RestResult } from '@/shared/types';
interface RestModalProps {
campaignId: string;
characterId: string;
onClose: () => void;
onRestComplete: (result: RestResult) => void;
}
export function RestModal({ campaignId, characterId, onClose, onRestComplete }: RestModalProps) {
const [preview, setPreview] = useState<RestPreview | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isResting, setIsResting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadPreview();
}, [campaignId, characterId]);
const loadPreview = async () => {
try {
setIsLoading(true);
const data = await api.getRestPreview(campaignId, characterId);
setPreview(data);
} catch (err) {
setError('Fehler beim Laden der Vorschau');
console.error('Failed to load rest preview:', err);
} finally {
setIsLoading(false);
}
};
const handleRest = async () => {
try {
setIsResting(true);
const result = await api.performRest(campaignId, characterId);
onRestComplete(result);
onClose();
} catch (err) {
setError('Fehler beim Rasten');
console.error('Failed to perform rest:', err);
} finally {
setIsResting(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onClose}>
<Card className="w-full max-w-md" onClick={(e) => e.stopPropagation()}>
<div className="p-4 border-b border-border">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-indigo-500/20 flex items-center justify-center">
<Moon className="h-5 w-5 text-indigo-400" />
</div>
<div>
<h2 className="text-lg font-bold text-text-primary">Rasten</h2>
<p className="text-xs text-text-secondary">8 Stunden + Tägliche Vorbereitung</p>
</div>
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-5 w-5" />
</Button>
</div>
</div>
<CardContent className="p-4">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-primary-500" />
</div>
) : error ? (
<div className="text-center py-4 text-red-400">{error}</div>
) : preview ? (
<div className="space-y-4">
{/* HP Healing */}
{preview.hpToHeal > 0 && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
<Heart className="h-5 w-5 text-green-400 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-green-400">HP-Heilung</p>
<p className="text-xs text-text-secondary">
+{preview.hpToHeal} HP (auf {preview.hpAfterRest})
</p>
</div>
</div>
)}
{/* Conditions to Remove */}
{preview.conditionsToRemove.length > 0 && (
<div className="flex items-start gap-3 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
<Shield className="h-5 w-5 text-red-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-400">Zustände entfernen</p>
<div className="flex flex-wrap gap-1 mt-1">
{preview.conditionsToRemove.map((c, i) => (
<span key={i} className="text-xs px-2 py-0.5 rounded bg-red-500/20 text-red-300">
{c}
</span>
))}
</div>
</div>
</div>
)}
{/* Conditions to Reduce */}
{preview.conditionsToReduce.length > 0 && (
<div className="flex items-start gap-3 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
<Shield className="h-5 w-5 text-yellow-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-yellow-400">Zustände reduzieren</p>
<div className="flex flex-wrap gap-1 mt-1">
{preview.conditionsToReduce.map((c, i) => (
<span key={i} className="text-xs px-2 py-0.5 rounded bg-yellow-500/20 text-yellow-300">
{c.name} {c.oldValue} {c.newValue}
</span>
))}
</div>
</div>
</div>
)}
{/* Resources to Reset */}
{preview.resourcesToReset.length > 0 && (
<div className="flex items-start gap-3 p-3 rounded-lg bg-blue-500/10 border border-blue-500/20">
<Sparkles className="h-5 w-5 text-blue-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-blue-400">Ressourcen auffüllen</p>
<div className="flex flex-wrap gap-1 mt-1">
{preview.resourcesToReset.map((r, i) => (
<span key={i} className="text-xs px-2 py-0.5 rounded bg-blue-500/20 text-blue-300">
{r}
</span>
))}
</div>
</div>
</div>
)}
{/* Alchemy Reset */}
{preview.alchemyReset && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-purple-500/10 border border-purple-500/20">
<FlaskConical className="h-5 w-5 text-purple-400 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-purple-400">Alchemie zurücksetzen</p>
<p className="text-xs text-text-secondary">
{preview.infusedItemsCount > 0
? `${preview.infusedItemsCount} infundierte Items verfallen, Phiolen werden aufgefüllt`
: 'Phiolen werden aufgefüllt'}
</p>
</div>
</div>
)}
{/* Nothing to do */}
{preview.hpToHeal === 0 &&
preview.conditionsToRemove.length === 0 &&
preview.conditionsToReduce.length === 0 &&
preview.resourcesToReset.length === 0 &&
!preview.alchemyReset && (
<div className="text-center py-4 text-text-secondary">
<p>Dein Charakter ist bereits vollständig erholt.</p>
</div>
)}
</div>
) : null}
{/* Action Buttons */}
<div className="flex gap-2 mt-6">
<Button variant="outline" className="flex-1" onClick={onClose}>
Abbrechen
</Button>
<Button
className="flex-1 bg-indigo-600 hover:bg-indigo-700"
onClick={handleRest}
disabled={isLoading || isResting}
>
{isResting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Raste...
</>
) : (
<>
<Moon className="h-4 w-4 mr-2" />
Rasten
</>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,11 +1,18 @@
import { useEffect, useRef, useCallback } from 'react';
import { useEffect, useRef, useCallback, useState } from 'react';
import { io, Socket } from 'socket.io-client';
import { api } from '@/shared/lib/api';
import type { Character, CharacterItem, CharacterCondition } from '@/shared/types';
import type { Character, CharacterItem, CharacterCondition, CharacterAlchemyState, CharacterFormula, CharacterPreparedItem, ConditionReduced } from '@/shared/types';
const SOCKET_URL = import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:3001';
// Derive WebSocket URL from API URL (remove /api suffix)
const SOCKET_URL = import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:5000';
export type CharacterUpdateType = 'hp' | 'conditions' | 'item' | 'inventory' | 'money' | 'level' | 'equipment_status';
// Singleton socket manager to prevent multiple connections
let globalSocket: Socket | null = null;
let globalSocketRefCount = 0;
let currentCharacterId: string | null = null;
let connectionAttempted = false;
export type CharacterUpdateType = 'hp' | 'conditions' | 'item' | 'inventory' | 'money' | 'level' | 'equipment_status' | 'rest' | 'alchemy_vials' | 'alchemy_formulas' | 'alchemy_prepared' | 'alchemy_state';
export interface CharacterUpdate {
characterId: string;
@@ -13,6 +20,15 @@ export interface CharacterUpdate {
data: any;
}
export interface RestUpdateData {
hpCurrent: number;
hpHealed: number;
conditionsRemoved: string[];
conditionsReduced: ConditionReduced[];
resourcesReset: string[];
alchemyReset: boolean;
}
interface UseCharacterSocketOptions {
characterId: string;
onHpUpdate?: (data: { hpCurrent: number; hpTemp: number; hpMax: number }) => void;
@@ -21,6 +37,11 @@ interface UseCharacterSocketOptions {
onEquipmentStatusUpdate?: (data: { action: 'update'; item: CharacterItem }) => void;
onMoneyUpdate?: (data: { credits: number }) => void;
onLevelUpdate?: (data: { level: number }) => void;
onRestUpdate?: (data: RestUpdateData) => void;
onAlchemyVialsUpdate?: (data: { versatileVialsCurrent: number }) => void;
onAlchemyFormulasUpdate?: (data: { action: 'add' | 'remove'; formula?: CharacterFormula; formulaId?: string }) => void;
onAlchemyPreparedUpdate?: (data: { action: 'add' | 'update' | 'remove' | 'prepare' | 'quick_alchemy'; item?: CharacterPreparedItem; items?: CharacterPreparedItem[]; itemId?: string; batchUsed?: number }) => void;
onAlchemyStateUpdate?: (data: CharacterAlchemyState) => void;
onFullUpdate?: (character: Character) => void;
}
@@ -32,114 +53,202 @@ export function useCharacterSocket({
onEquipmentStatusUpdate,
onMoneyUpdate,
onLevelUpdate,
onRestUpdate,
onAlchemyVialsUpdate,
onAlchemyFormulasUpdate,
onAlchemyPreparedUpdate,
onAlchemyStateUpdate,
onFullUpdate,
}: UseCharacterSocketOptions) {
const socketRef = useRef<Socket | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isConnected, setIsConnected] = useState(false);
const mountedRef = useRef(true);
const connect = useCallback(() => {
// Use refs for callbacks to avoid reconnection on callback changes
const callbacksRef = useRef({
onHpUpdate,
onConditionsUpdate,
onInventoryUpdate,
onEquipmentStatusUpdate,
onMoneyUpdate,
onLevelUpdate,
onRestUpdate,
onAlchemyVialsUpdate,
onAlchemyFormulasUpdate,
onAlchemyPreparedUpdate,
onAlchemyStateUpdate,
onFullUpdate,
});
// Update refs when callbacks change (without causing reconnection)
useEffect(() => {
callbacksRef.current = {
onHpUpdate,
onConditionsUpdate,
onInventoryUpdate,
onEquipmentStatusUpdate,
onMoneyUpdate,
onLevelUpdate,
onRestUpdate,
onAlchemyVialsUpdate,
onAlchemyFormulasUpdate,
onAlchemyPreparedUpdate,
onAlchemyStateUpdate,
onFullUpdate,
};
});
useEffect(() => {
mountedRef.current = true;
const token = api.getToken();
if (!token || !characterId) return;
// Disconnect existing socket if any
if (socketRef.current?.connected) {
socketRef.current.disconnect();
// Increment ref count
globalSocketRefCount++;
// If we already have a socket for a different character, leave that room first
if (globalSocket?.connected && currentCharacterId && currentCharacterId !== characterId) {
globalSocket.emit('leave_character', { characterId: currentCharacterId });
}
const socket = io(`${SOCKET_URL}/characters`, {
auth: { token },
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
});
// Update current character
currentCharacterId = characterId;
socket.on('connect', () => {
console.log('[WebSocket] Connected to character namespace');
// Join the character room
socket.emit('join_character', { characterId }, (response: { success: boolean; error?: string }) => {
if (response.success) {
console.log(`[WebSocket] Joined character room: ${characterId}`);
} else {
console.error(`[WebSocket] Failed to join character room: ${response.error}`);
// Create socket if it doesn't exist and we haven't already tried
if (!globalSocket && !connectionAttempted) {
connectionAttempted = true;
globalSocket = io(`${SOCKET_URL}/characters`, {
auth: { token },
// Start with polling to avoid browser WebSocket errors when server is down
// Socket.io will automatically upgrade to websocket when connected
transports: ['polling', 'websocket'],
upgrade: true,
reconnection: true,
reconnectionAttempts: 3,
reconnectionDelay: 3000,
reconnectionDelayMax: 10000,
timeout: 10000,
autoConnect: true,
});
globalSocket.on('connect', () => {
if (!mountedRef.current) return;
console.log('[WebSocket] Connected');
setIsConnected(true);
// Join character room on connect/reconnect
if (currentCharacterId && globalSocket) {
globalSocket.emit('join_character', { characterId: currentCharacterId }, (response: { success: boolean; error?: string }) => {
if (response.success) {
console.log(`[WebSocket] Joined room: ${currentCharacterId}`);
}
});
}
});
});
socket.on('disconnect', (reason) => {
console.log(`[WebSocket] Disconnected: ${reason}`);
});
globalSocket.on('disconnect', (reason) => {
console.log(`[WebSocket] Disconnected: ${reason}`);
if (mountedRef.current) {
setIsConnected(false);
}
});
socket.on('connect_error', (error) => {
console.error('[WebSocket] Connection error:', error.message);
});
globalSocket.on('connect_error', () => {
// Silently handle - socket.io will retry automatically
if (mountedRef.current) {
setIsConnected(false);
}
});
// Handle character updates
socket.on('character_update', (update: CharacterUpdate) => {
console.log(`[WebSocket] Received update: ${update.type}`, update.data);
// Handle character updates - use refs to always call latest callbacks
globalSocket.on('character_update', (update: CharacterUpdate) => {
if (!mountedRef.current) return;
const callbacks = callbacksRef.current;
switch (update.type) {
case 'hp':
onHpUpdate?.(update.data);
break;
case 'conditions':
onConditionsUpdate?.(update.data);
break;
case 'inventory':
onInventoryUpdate?.(update.data);
break;
case 'equipment_status':
onEquipmentStatusUpdate?.(update.data);
break;
case 'money':
onMoneyUpdate?.(update.data);
break;
case 'level':
onLevelUpdate?.(update.data);
break;
case 'item':
// Item update that's not equipment status (e.g., quantity, notes)
onInventoryUpdate?.(update.data);
break;
}
});
switch (update.type) {
case 'hp':
callbacks.onHpUpdate?.(update.data);
break;
case 'conditions':
callbacks.onConditionsUpdate?.(update.data);
break;
case 'inventory':
callbacks.onInventoryUpdate?.(update.data);
break;
case 'equipment_status':
callbacks.onEquipmentStatusUpdate?.(update.data);
break;
case 'money':
callbacks.onMoneyUpdate?.(update.data);
break;
case 'level':
callbacks.onLevelUpdate?.(update.data);
break;
case 'item':
callbacks.onInventoryUpdate?.(update.data);
break;
case 'rest':
callbacks.onRestUpdate?.(update.data);
break;
case 'alchemy_vials':
callbacks.onAlchemyVialsUpdate?.(update.data);
break;
case 'alchemy_formulas':
callbacks.onAlchemyFormulasUpdate?.(update.data);
break;
case 'alchemy_prepared':
callbacks.onAlchemyPreparedUpdate?.(update.data);
break;
case 'alchemy_state':
callbacks.onAlchemyStateUpdate?.(update.data);
break;
}
});
// Handle full character refresh (e.g., after reconnect)
socket.on('character_refresh', (character: Character) => {
console.log('[WebSocket] Received full character refresh');
onFullUpdate?.(character);
});
socketRef.current = socket;
return socket;
}, [characterId, onHpUpdate, onConditionsUpdate, onInventoryUpdate, onEquipmentStatusUpdate, onMoneyUpdate, onLevelUpdate, onFullUpdate]);
const disconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
globalSocket.on('character_refresh', (character: Character) => {
if (!mountedRef.current) return;
callbacksRef.current.onFullUpdate?.(character);
});
} else if (globalSocket?.connected) {
// Socket already exists and connected, just join the room
globalSocket.emit('join_character', { characterId }, (response: { success: boolean; error?: string }) => {
if (response.success) {
console.log(`[WebSocket] Joined room: ${characterId}`);
}
});
}
if (socketRef.current) {
// Leave the character room before disconnecting
socketRef.current.emit('leave_character', { characterId });
socketRef.current.disconnect();
socketRef.current = null;
}
}, [characterId]);
useEffect(() => {
connect();
// Cleanup on unmount
return () => {
disconnect();
mountedRef.current = false;
globalSocketRefCount--;
if (globalSocket && currentCharacterId === characterId) {
globalSocket.emit('leave_character', { characterId });
}
// Only disconnect socket if no more refs
if (globalSocketRefCount <= 0 && globalSocket) {
globalSocket.disconnect();
globalSocket = null;
currentCharacterId = null;
connectionAttempted = false;
}
};
}, [connect, disconnect]);
}, [characterId]); // Only depend on characterId
const reconnect = useCallback(() => {
if (globalSocket) {
globalSocket.connect();
} else {
// Reset connection attempt flag to allow new connection
connectionAttempted = false;
}
}, []);
return {
socket: socketRef.current,
isConnected: socketRef.current?.connected ?? false,
reconnect: connect,
socket: globalSocket,
isConnected,
reconnect,
};
}

View File

@@ -373,6 +373,107 @@ class ApiClient {
const response = await this.client.get(`/feats/by-name/${encodeURIComponent(name)}`);
return response.data;
}
// ==========================================
// REST SYSTEM
// ==========================================
async getRestPreview(campaignId: string, characterId: string) {
const response = await this.client.get(`/campaigns/${campaignId}/characters/${characterId}/rest/preview`);
return response.data;
}
async performRest(campaignId: string, characterId: string) {
const response = await this.client.post(`/campaigns/${campaignId}/characters/${characterId}/rest`);
return response.data;
}
// ==========================================
// ALCHEMY SYSTEM
// ==========================================
async getAlchemy(campaignId: string, characterId: string) {
const response = await this.client.get(`/campaigns/${campaignId}/characters/${characterId}/alchemy`);
return response.data;
}
async initializeAlchemy(campaignId: string, characterId: string, data: {
researchField?: 'BOMBER' | 'CHIRURGEON' | 'MUTAGENIST' | 'TOXICOLOGIST';
versatileVialsMax: number;
advancedAlchemyMax: number;
}) {
const response = await this.client.post(`/campaigns/${campaignId}/characters/${characterId}/alchemy/initialize`, data);
return response.data;
}
async updateVials(campaignId: string, characterId: string, current: number) {
const response = await this.client.patch(`/campaigns/${campaignId}/characters/${characterId}/alchemy/vials`, { current });
return response.data;
}
async refillVials(campaignId: string, characterId: string) {
const response = await this.client.post(`/campaigns/${campaignId}/characters/${characterId}/alchemy/vials/refill`);
return response.data;
}
async getFormulas(campaignId: string, characterId: string) {
const response = await this.client.get(`/campaigns/${campaignId}/characters/${characterId}/alchemy/formulas`);
return response.data;
}
async getAvailableFormulas(campaignId: string, characterId: string) {
const response = await this.client.get(`/campaigns/${campaignId}/characters/${characterId}/alchemy/formulas/available`);
return response.data;
}
async addFormula(campaignId: string, characterId: string, data: {
equipmentId: string;
learnedAt?: number;
formulaSource?: string;
}) {
const response = await this.client.post(`/campaigns/${campaignId}/characters/${characterId}/alchemy/formulas`, data);
return response.data;
}
async removeFormula(campaignId: string, characterId: string, formulaId: string) {
const response = await this.client.delete(`/campaigns/${campaignId}/characters/${characterId}/alchemy/formulas/${formulaId}`);
return response.data;
}
async refreshFormulaTranslations(campaignId: string, characterId: string) {
const response = await this.client.post(`/campaigns/${campaignId}/characters/${characterId}/alchemy/formulas/refresh-translations`);
return response.data;
}
async getPreparedItems(campaignId: string, characterId: string) {
const response = await this.client.get(`/campaigns/${campaignId}/characters/${characterId}/alchemy/prepared`);
return response.data;
}
async dailyPreparation(campaignId: string, characterId: string, items: Array<{ equipmentId: string; quantity: number }>) {
const response = await this.client.post(`/campaigns/${campaignId}/characters/${characterId}/alchemy/prepare`, { items });
return response.data;
}
async quickAlchemy(campaignId: string, characterId: string, equipmentId: string) {
const response = await this.client.post(`/campaigns/${campaignId}/characters/${characterId}/alchemy/quick`, { equipmentId });
return response.data;
}
async craftAlchemy(campaignId: string, characterId: string, equipmentId: string, quantity: number) {
const response = await this.client.post(`/campaigns/${campaignId}/characters/${characterId}/alchemy/craft`, { equipmentId, quantity });
return response.data;
}
async consumePreparedItem(campaignId: string, characterId: string, itemId: string) {
const response = await this.client.patch(`/campaigns/${campaignId}/characters/${characterId}/alchemy/prepared/${itemId}/consume`);
return response.data;
}
async deletePreparedItem(campaignId: string, characterId: string, itemId: string) {
const response = await this.client.delete(`/campaigns/${campaignId}/characters/${characterId}/alchemy/prepared/${itemId}`);
return response.data;
}
}
export const api = new ApiClient();

View File

@@ -78,6 +78,10 @@ export interface Character extends CharacterSummary {
items: CharacterItem[];
conditions: CharacterCondition[];
resources: CharacterResource[];
// Alchemy
alchemyState?: CharacterAlchemyState;
formulas?: CharacterFormula[];
preparedItems?: CharacterPreparedItem[];
}
export interface CharacterAbility {
@@ -158,6 +162,78 @@ export interface CharacterResource {
max: number;
}
// Alchemy Types
export type ResearchField = 'BOMBER' | 'CHIRURGEON' | 'MUTAGENIST' | 'TOXICOLOGIST';
export interface CharacterAlchemyState {
id: string;
characterId: string;
researchField?: ResearchField;
versatileVialsCurrent: number;
versatileVialsMax: number;
advancedAlchemyBatch: number;
advancedAlchemyMax: number;
lastRestAt?: string;
}
export interface CharacterFormula {
id: string;
characterId: string;
equipmentId: string;
name: string;
nameGerman?: string;
learnedAt: number;
formulaSource?: string;
equipment?: Equipment;
}
export interface CharacterPreparedItem {
id: string;
characterId: string;
equipmentId: string;
name: string;
nameGerman?: string;
quantity: number;
isQuickAlchemy: boolean;
isInfused: boolean;
createdAt: string;
equipment?: Equipment;
}
// Available formula - equipment with isLearned flag for auto-upgrade system
export interface AvailableFormula extends Equipment {
nameGerman?: string;
summaryGerman?: string;
effectGerman?: string;
isLearned: boolean; // true if this exact version was learned, false if it's an upgraded version
}
// Rest Types
export interface ConditionReduced {
name: string;
oldValue: number;
newValue: number;
}
export interface RestPreview {
hpToHeal: number;
hpAfterRest: number;
conditionsToRemove: string[];
conditionsToReduce: ConditionReduced[];
resourcesToReset: string[];
alchemyReset: boolean;
infusedItemsCount: number;
}
export interface RestResult {
hpHealed: number;
hpCurrent: number;
conditionsRemoved: string[];
conditionsReduced: ConditionReduced[];
resourcesReset: string[];
alchemyReset: boolean;
}
// Battle Types
export interface BattleMap {
id: string;
@@ -281,6 +357,8 @@ export interface Equipment {
activation?: string;
duration?: string;
usage?: string;
effect?: string; // Specific effect text for item variants (Lesser, Moderate, Greater, Major)
effectGerman?: string; // Translated effect text
}
export interface EquipmentSearchResult {

View File

@@ -11,8 +11,8 @@ JWT_EXPIRES_IN="7d"
PORT=5000
NODE_ENV=development
# CORS Origins (comma separated)
CORS_ORIGINS="http://localhost:3000,http://localhost:5173"
# CORS Origins (comma separated, must include frontend dev server port)
CORS_ORIGINS="http://localhost:3000,http://localhost:5173,http://localhost:5175"
# Claude API (for translations)
ANTHROPIC_API_KEY=""

View File

@@ -16,6 +16,7 @@
"db:seed": "tsx prisma/seed.ts",
"db:seed:equipment": "tsx prisma/seed-equipment.ts",
"db:seed:feats": "tsx prisma/seed-feats.ts",
"db:update:levels": "tsx prisma/update-equipment-levels.ts",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",

View File

@@ -0,0 +1,822 @@
[
{
"name": "Abysium Powder",
"effect": "Saving Throw DC 27 Fortitude; Maximum Duration 6 minutes; Stage 1 8d6 poi; Stage 2 9d6 poi; Stage 3 10d6 poi"
},
{
"name": "Achaekek's Kiss",
"effect": "Saving Throw DC 42 Fortitude; Maximum Duration 6 rounds; Stage 1 7d12 poi; Stage 2 9d12 poi; Stage 3 11d12 poi"
},
{
"name": "Addiction Suppressant (Lesser)",
"effect": "You gain a +1 item bonus."
},
{
"name": "Addiction Suppressant (Moderate)",
"effect": "You gain a +2 item bonus."
},
{
"name": "Addiction Suppressant (Greater)",
"effect": "You gain a +3 item bonus."
},
{
"name": "Addiction Suppressant (Major)",
"effect": "You gain a +4 item bonus."
},
{
"name": "Addlebrain",
"effect": "Saving Throw DC 25 Fortitude; Maximum Duration 1 day; Stage 1 enfeebled 1 and; Stage 2 enfeebled 2 and; Stage 3 fatigued , enfeebled 4, and"
},
{
"name": "Affliction Suppressant (Lesser)",
"effect": "You gain a +1 item bonus."
},
{
"name": "Affliction Suppressant (Moderate)",
"effect": "You gain a +2 item bonus."
},
{
"name": "Affliction Suppressant (Greater)",
"effect": "You gain a +3 item bonus."
},
{
"name": "Affliction Suppressant (Major)",
"effect": "You gain a +4 item bonus, and when you drink the affliction suppressant, you can attempt a save against one affliction of 14th level or lower affecting you."
},
{
"name": "Ambrosia of Undying Hope",
"effect": "gain 20 Hit Points, the elixir's benefits end, and you become temporarily immune to the ambrosia of undying hope for 24 hours."
},
{
"name": "Antidote (Lesser)",
"effect": "You gain a +2 item bonus."
},
{
"name": "Antidote (Moderate)",
"effect": "You gain a +3 item bonus."
},
{
"name": "Antidote (Greater)",
"effect": "You gain a +4 item bonus."
},
{
"name": "Antiplague (Lesser)",
"effect": "You gain a +2 item bonus."
},
{
"name": "Antiplague (Moderate)",
"effect": "You gain a +3 item bonus."
},
{
"name": "Antiplague (Greater)",
"effect": "You gain a +4 item bonus."
},
{
"name": "Antiplague (Major)",
"effect": "You gain a +4 item bonus, and when you drink the antiplague, you can immediately attempt a saving throw against one disease of 14th level or lower affecting you."
},
{
"name": "Antipode Oil",
"effect": "Saving Throw DC 24 Fortitude; Maximum Duration 6 rounds; Stage 1 2d6 cold or fire damage (1 round); Stage 2 3d6 cold or fire damage (1 round)"
},
{
"name": "Apricot of Bestial Might",
"effect": "gain 8 resistance to all physical damage and gain a tusk unarmed attack with the deadly d12 trait that deals 1d10 piercing damage."
},
{
"name": "Arsenic",
"effect": "Saving Throw DC 18 Fortitude; Maximum Duration 5 minutes; Stage 1 1d4 poi; Stage 2 1d6 poi; Stage 3 1d8 poi"
},
{
"name": "Astringent Venom",
"effect": "Saving Throw DC 32 Fortitude; Maximum Duration 6 rounds; Stage 1 6d6 poi; Stage 2 8d6 poi; Stage 3 10d6 poi"
},
{
"name": "Baleblood Draft",
"effect": "gain a +4 circumstance bonus."
},
{
"name": "Belladonna",
"effect": "Saving Throw DC 19 Fortitude; Maximum Duration 30 minutes; Stage 1 dazzled (10 minute; Stage 2 1d6 poi; Stage 3 1d6 poi"
},
{
"name": "Bendy-Arm Mutagen (Lesser)",
"effect": "0 Price 3 gp Bulk L The bonus is +1, your reach increases by 5 feet, and the duration is 1 minute."
},
{
"name": "Bendy-Arm Mutagen (Moderate)",
"effect": "0 Price 12 gp Bulk L The bonus is +2, your reach increases by 5 feet, and the duration is 10 minutes."
},
{
"name": "Bendy-Arm Mutagen (Greater)",
"effect": "0 Price 300 gp Bulk L The bonus is +3, your reach increases by 10 feet, and the duration is 1 hour."
},
{
"name": "Bendy-Arm Mutagen (Major)",
"effect": "0 Price 3,000 gp Bulk L The bonus is +4, your reach increases by 15 feet, and the duration is 1 hour."
},
{
"name": "Blackfinger Blight",
"effect": "Saving Throw DC 32 Fortitude; Maximum Duration 6 rounds; Stage 1 6d6 poi; Stage 2 8d6 poi; Stage 3 10d6 poi"
},
{
"name": "Blightburn Resin",
"effect": "Saving Throw DC 30 Fortitude; Maximum Duration 6 rounds; Stage 1 6d6 poi; Stage 2 7d6 poi; Stage 3 9d6 poi"
},
{
"name": "Blisterwort",
"effect": "Saving Throw DC 30 Fortitude; Maximum Duration 6 rounds; Stage 1 4d6 poi; Stage 2 5d6 poi; Stage 3 7d6 poi"
},
{
"name": "Blue Dragonfly Poison",
"effect": "Saving Throw DC 17 Fortitude; Maximum Duration 30 minutes; Stage 1 dazzled (10 minute; Stage 2 dazzled and frightened 1 (10 minute; Stage 3 frightened 1 and confu"
},
{
"name": "Bogeyman Breath",
"effect": "Saving Throw DC 28 Fortitude; Maximum Duration 6 rounds; Stage 1 4d6 mental damage, frightened 1, and cant reduce frightened value for 1 round (1 round); Stage 2 4d6 mental damage, frightened 2, and cant reduce frightened value for 1 round (1 round); Stage 3 4d6 mental damage, frightened 2, fleeing the poi"
},
{
"name": "Bottled Catharsis (Minor)",
"effect": "The elixir counteracts at 1st-rank and has a +6 counteract modifier."
},
{
"name": "Bottled Catharsis (Lesser)",
"effect": "The elixir counteracts at 2nd-rank and has a +8 counteract modifier."
},
{
"name": "Bottled Catharsis (Moderate)",
"effect": "The elixir counteracts at 4th-rank and has a +14 counteract modifier."
},
{
"name": "Bottled Catharsis (Greater)",
"effect": "The elixir counteracts at 6th-rank and has a +19 counteract modifier."
},
{
"name": "Bottled Catharsis (Major)",
"effect": "The elixir counteracts at 9th-rank and has a +28 counteract modifier."
},
{
"name": "Boulderhead Bock",
"effect": "gain a +1 item bonus to saving throws against effects that would make you stunned or stupefied ."
},
{
"name": "Breath of the Mantis God",
"effect": "Saving Throw DC 29 Fortitude; Maximum Duration 6 minutes; Stage 1 3d6 per; Stage 2 3d8 per; Stage 3 3d10 per"
},
{
"name": "Breathtaking Vapor",
"effect": "Saving Throw DC 38 Fortitude; Maximum Duration 6 rounds; Stage 1 6d6 poi; Stage 2 8d6 poi; Stage 3 10d6 poi"
},
{
"name": "Brightshade",
"effect": "Saving Throw DC 21 Fortitude; Maximum Duration 6 rounds; Stage 1 1d6 poi; Stage 2 2d6 poi"
},
{
"name": "Brimstone Fumes",
"effect": "Saving Throw DC 36 Fortitude; Maximum Duration 6 rounds; Stage 1 7d8 poi; Stage 2 8d8 poi; Stage 3 10d8 poi"
},
{
"name": "Careless Delight",
"effect": "Saving Throw DC 28 Fortitude; Maximum Duration 10 minutes"
},
{
"name": "Cerulean Scourge",
"effect": "Saving Throw DC 37 Fortitude; Maximum Duration 6 rounds; Stage 1 10d6 poi; Stage 2 12d6 poi; Stage 3 14d6 poi"
},
{
"name": "Cheetah's Elixir (Lesser)",
"effect": "1 Price 3 gp Bulk L The bonus is +5 feet, and the duration is 1 minute."
},
{
"name": "Cheetah's Elixir (Moderate)",
"effect": "1 Price 25 gp Bulk L The bonus is +10 feet, and the duration is 10 minutes."
},
{
"name": "Cheetah's Elixir (Greater)",
"effect": "1 Price 110 gp Bulk L The bonus is +10 feet, and the duration is 1 hour."
},
{
"name": "Choleric Contagion",
"effect": "Saving Throw DC 40 Fortitude; Maximum Duration 6 rounds; Stage 1 6d10 poi; Stage 2 8d10 poi; Stage 3 10d10 poi"
},
{
"name": "Clown Monarch",
"effect": "Saving Throw DC 22 Fortitude; Maximum Duration 6 rounds; Stage 1 fall; stage 1 but the DC i; stage 1 but the DC i"
},
{
"name": "Clubhead Poison",
"effect": "Saving Throw DC 32 Fortitude; Maximum Duration 6 rounds; Stage 1 3d8 poi; Stage 2 4d8 poi; Stage 3 5d8 poi"
},
{
"name": "Cognitive Mutagen (Lesser)",
"effect": "1 Price 4 gp Bulk L he bonus is +1, and the duration is 1 minute."
},
{
"name": "Cognitive Mutagen (Moderate)",
"effect": "1 Price 12 gp Bulk L The bonus is +2, and the duration is 10 minutes."
},
{
"name": "Cognitive Mutagen (Greater)",
"effect": "1 Price 300 gp Bulk L The bonus is +3, and the duration is 1 hour."
},
{
"name": "Cognitive Mutagen (Major)",
"effect": "1 Price 3,000 gp Bulk L The bonus is +4, and the duration is 1 hour."
},
{
"name": "Contagion Metabolizer (Lesser)",
"effect": "The elixir has a counteract rank of 3 and a +11 counteract modifier."
},
{
"name": "Contagion Metabolizer (Moderate)",
"effect": "The elixir has a counteract rank of 6 and a +19 counteract modifier."
},
{
"name": "Contagion Metabolizer (Greater)",
"effect": "The elixir has a counteract rank of 10 and a +30 counteract modifier."
},
{
"name": "Curare",
"effect": "Saving Throw DC 25 Fortitude; Maximum Duration 6 rounds (but see stage 3); Stage 1 2d6 poi; Stage 2 3d6 poi; Stage 3 4d6 poi"
},
{
"name": "Cytillesh Oil",
"effect": "Saving Throw DC 19 Fortitude; Maximum Duration 4 rounds; Stage 1 1d8 poi; Stage 2 1d10 poi; Stage 3 2d8 poi"
},
{
"name": "Dancing Lamentation",
"effect": "Saving Throw DC 30 Fortitude; Maximum Duration 6 rounds; Stage 1 4d6 poi; Stage 2 6d6 poi; Stage 3 8d6 poi"
},
{
"name": "Daylight Vapor",
"effect": "Saving Throw DC 31 Fortitude; Maximum Duration 6 rounds; Stage 1 4d6 poi; Stage 2 6d6 poi; Stage 3 10d6 damage and"
},
{
"name": "Deadweight Mutagen (Lesser)",
"effect": "0 Price 3 gp Bulk L The bonus is +1, and the duration is 1 minute."
},
{
"name": "Deadweight Mutagen (Moderate)",
"effect": "0 Price 12 gp Bulk L The bonus is +2, and the duration is 10 minutes."
},
{
"name": "Deadweight Mutagen (Greater)",
"effect": "0 Price 300 gp Bulk L The bonus is +3, and the duration is 1 hour."
},
{
"name": "Deadweight Mutagen (Major)",
"effect": "0 Price 3,000 gp Bulk L The bonus is +4, and the duration is 1 hour."
},
{
"name": "Deathcap Powder",
"effect": "Saving Throw DC 33 Fortitude; Maximum Duration 6 minutes; Stage 1 7d8 poi; Stage 2 9d6 poi; Stage 3 8d10 poi"
},
{
"name": "Deathstalk Mushroom",
"effect": "Saving Throw DC 35 Fortitude; Maximum Duration 6 minutes; Stage 2 confu; Stage 3 16d6 poi; Stage 4 17d6 poi"
},
{
"name": "Dragon Bile",
"effect": "Saving Throw DC 37 Fortitude; Maximum Duration 6 rounds; Stage 1 6d6 poi; Stage 2 7d6 poi; Stage 3 9d6 poi"
},
{
"name": "Eagle-Eye Elixir (Lesser)",
"effect": "1 Price 4 gp Bulk L The bonus is +1, or +2 to find secret doors and traps."
},
{
"name": "Eagle-Eye Elixir (Moderate)",
"effect": "1 Price 27 gp Bulk L The bonus is +2, or +3 to find secret doors and traps."
},
{
"name": "Eagle-Eye Elixir (Greater)",
"effect": "1 Price 200 gp Bulk L The bonus is +3, or +4 to find secret doors and traps."
},
{
"name": "Eagle-Eye Elixir (Major)",
"effect": "1 Price 2,000 gp Bulk L The bonus is +3, or +4 to find secret doors and traps."
},
{
"name": "Eldritch Flare",
"effect": "Saving Throw DC 35 Fortitude; Maximum Duration 6 rounds; Stage 1 8d6 damage (1 round); Stage 2 10d6 damage (1 round); Stage 3 12d6 damage (1 round)"
},
{
"name": "Elixir of Gender Transformation (Lesser)",
"effect": "The elixir must be taken every week, and changes occur over the course of a year or more."
},
{
"name": "Elixir of Gender Transformation (Moderate)",
"effect": "The elixir must be taken once a month, and changes occur over the course of a year."
},
{
"name": "Elixir of Gender Transformation (Greater)",
"effect": "The elixir must be taken once, and changes occur over the course of 6 months."
},
{
"name": "Elixir of Life (Minor)",
"effect": "The elixir restores 1d6 Hit Points, and the bonus is +1."
},
{
"name": "Elixir of Life (Lesser)",
"effect": "The elixir restores 3d6+6 Hit Points, and the bonus is +1."
},
{
"name": "Elixir of Life (Moderate)",
"effect": "The elixir restores 5d6+12 Hit Points, and the bonus is +2."
},
{
"name": "Elixir of Life (Greater)",
"effect": "The elixir restores 7d6+18 Hit Points, and the bonus is +2."
},
{
"name": "Elixir of Life (Major)",
"effect": "The elixir restores 8d6+21 Hit Points, and the bonus is +3."
},
{
"name": "Elixir of Life (True)",
"effect": "The elixir restores 10d6+27 Hit Points, and the bonus is +4."
},
{
"name": "Energy Mutagen (Lesser)",
"effect": "You gain resistance 5, add 1 damage on a hit with a melee weapon, and the duration is 1 minute."
},
{
"name": "Energy Mutagen (Moderate)",
"effect": "You gain resistance 10, add 1d4 damage on a hit with a melee weapon, and the duration is 10 minutes."
},
{
"name": "Energy Mutagen (Greater)",
"effect": "You gain resistance 15, add 1d6 damage on a hit with a melee weapon, and the duration is 1 hour."
},
{
"name": "Energy Mutagen (Major)",
"effect": "You gain resistance 20, add 2d6 damage on a hit with a melee weapon, and the duration is 1 hour."
},
{
"name": "Enervating Powder",
"effect": "Saving Throw DC 28 Fortitude; Maximum Duration 6 minutes; Stage 1 fatigued (1 minute); Stage 2 5d6 poi; Stage 3 6d6 poi"
},
{
"name": "Essence of Mandragora",
"effect": "Saving Throw DC 21 Fortitude; Maximum Duration 6 rounds; Stage 1 1d6 poi; Stage 2 1d6 poi; Stage 3 2d6 poi"
},
{
"name": "Execution Powder",
"effect": "Saving Throw DC 34 Fortitude; Maximum Duration 6 rounds; Stage 1 7d6 poi; Stage 2 9d6 poi; Stage 3 12d6 poi"
},
{
"name": "False Death",
"effect": "Saving Throw DC 18 Fortitude; Maximum Duration 5 days; Stage 1 clum; Stage 2 uncon; Stage 3 uncon"
},
{
"name": "False Flayleaf",
"effect": "Saving Throw DC 19 Fortitude; Maximum Duration 30 minutes; Stage 1 dazzled (10 minute; Stage 2 1d6 poi; Stage 3 1d6 poi"
},
{
"name": "False Hope",
"effect": "Saving Throw DC 37 Fortitude; Maximum Duration 10 rounds; Stage 1 no effect (1 round); Stage 2 10d8 poi; Stage 3 no effect; Stage 4 12d8 poi"
},
{
"name": "Fearflower Nectar",
"effect": "Saving Throw DC 21 Fortitude; Maximum Duration 6 rounds; Stage 1 1d6 poi; Stage 2 1d6 poi; Stage 3 1d6 poi"
},
{
"name": "Fearweed",
"effect": "Saving Throw DC 30 Fortitude; Maximum Duration 6 minutes; Stage 1 7d6 poi; Stage 2 8d6 poi; Stage 3 9d6 poi"
},
{
"name": "Forgetful Drops",
"effect": "Saving Throw DC 18 Fortitude; Maximum Duration 1 hour"
},
{
"name": "Forgetful Ink",
"effect": "Saving Throw DC 20 Fortitude; Stage 1 The reader forget"
},
{
"name": "Fraudslayer Oil",
"effect": "Saving Throw DC 34 Fortitude; Maximum Duration 6 minutes"
},
{
"name": "Frenzy Oil",
"effect": "Saving Throw DC 37 Fortitude; Maximum Duration 6 rounds; Stage 1 4d6 mental damage, quickened 1 , attack nearby creature; Stage 2 6d6 mental damage, attack nearby creature; Stage 3 8d6 mental damage, fatigued , attack nearby creature"
},
{
"name": "Frogskin Tincture",
"effect": "Saving Throw DC 22 Fortitude; Maximum Duration 6 rounds; Stage 1 2d4 poi; Stage 2 2d6 poi; Stage 3 3d6 poi"
},
{
"name": "Giant Scorpion Venom",
"effect": "Saving Throw DC 22 Fortitude; Maximum Duration 6 rounds; Stage 1 2d6 poi; Stage 2 2d8 poi; Stage 3 2d10 poi"
},
{
"name": "Gnawbone Toxin",
"effect": "Saving Throw DC 30 Fortitude; Maximum Duration 6 minutes; Stage 1 enfeebled 2 (1 minute); Stage 2 enfeebled 3 (1 minute); Stage 3 enfeebled 4 (1d4 minute"
},
{
"name": "Gorgon's Breath",
"effect": "Saving Throw DC 32 Fortitude; Maximum Duration 6 rounds; Stage 2 4d6 bludgeoning damage and; Stage 3 petrified (1 round); Stage 4 petrified permanently"
},
{
"name": "Hemlock",
"effect": "Saving Throw DC 38 Fortitude; Maximum Duration 60 minutes; Stage 1 16d6 poi; Stage 2 17d6 poi; Stage 3 18d6 poi"
},
{
"name": "Honeyscent",
"effect": "Saving Throw DC 30 Will; Maximum Duration 6 rounds; Stage 1 2d6; Stage 2 2d6; Stage 3 2d6"
},
{
"name": "Hunger Oil",
"effect": "Saving Throw DC 30 Fortitude; Maximum Duration 6 minutes; Stage 1 enfeebled 2 (1 minute); Stage 2 enfeebled 3 (1 minute); Stage 3 enfeebled 4 (1d4 minute"
},
{
"name": "Infiltrator's Elixir",
"effect": "gain a +4 status bonus to your Deception DC to avoid others seeing through your disguise, and you add your level to this DC even if untrained."
},
{
"name": "Isolation Draught",
"effect": "Saving Throw DC 25 Fortitude; Maximum Duration 30 minutes; Stage 1 dazzled , 3 to all Perception check; Stage 2 dazzled, deafened , 5 to all Perception check; Stage 3 blinded , deafened, 5 to all Perception check"
},
{
"name": "Juggernaut Mutagen (Lesser)",
"effect": "you gain 5 temporary Hit Points, and the duration is 1 minute."
},
{
"name": "Juggernaut Mutagen (Moderate)",
"effect": "you gain 10 temporary Hit Points, and the duration is 10 minutes."
},
{
"name": "Juggernaut Mutagen (Greater)",
"effect": "you gain 30 temporary Hit Points, and the duration is 1 hour."
},
{
"name": "Juggernaut Mutagen (Major)",
"effect": "you gain 45 temporary Hit Points, and the duration is 1 hour."
},
{
"name": "King's Sleep",
"effect": "Saving Throw DC 41 Fortitude; Stage 1 drained 1 (1 day); Stage 2 drained 1 (1 day); Stage 3 drained 2 (1 day)"
},
{
"name": "Knockout Dram",
"effect": "Saving Throw DC 23 Fortitude; Maximum Duration 10 hours; Stage 1 fall uncon"
},
{
"name": "Leadenleg",
"effect": "Saving Throw DC 20 Fortitude; Maximum Duration 6 rounds; Stage 1 1d10 poi; Stage 2 2d6 poi; Stage 3 2d6 poi"
},
{
"name": "Lethargy Poison",
"effect": "Saving Throw DC 18 Fortitude; Maximum Duration 4 hours; Stage 3 uncon; Stage 4 uncon"
},
{
"name": "Liar's Demise",
"effect": "Saving Throw DC 34 Fortitude; Maximum Duration 6 minutes"
},
{
"name": "Lich Dust",
"effect": "Saving Throw DC 28 Fortitude; Maximum Duration 6 minutes; Stage 1 fatigued (1 minute); Stage 2 5d6 poi; Stage 3 5d6 poi"
},
{
"name": "Lifeblight Residue",
"effect": "Saving Throw DC 35 Fortitude; Maximum Duration 6 rounds; Stage 1 5d6 negative damage and 3d6 poi; Stage 2 6d6 negative damage and 4d6 poi; Stage 3 7d6 negative damage and 5d6 poi"
},
{
"name": "Looter's Lethargy",
"effect": "Saving Throw DC 19 Fortitude; Maximum Duration 1 hour; Stage 1 reduce Bulk limit by 3 (1 minute); Stage 2 off-guard , reduce Bulk limit by 4 (10 minute; Stage 3 off-guard, reduce Bulk limit by 5 (10 minute"
},
{
"name": "Mage Bane",
"effect": "Saving Throw DC 32 Fortitude; Maximum Duration 6 rounds; Stage 1 2d6 mental damage and; Stage 2 3d6 mental damage and; Stage 3 4d6 mental damage and"
},
{
"name": "Malyass Root Paste",
"effect": "Saving Throw DC 26 Fortitude; Maximum Duration 6 minutes; Stage 1 clum; Stage 2 clum; Stage 3 clum"
},
{
"name": "Mindfog Mist",
"effect": "Saving Throw DC 35 Fortitude; Maximum Duration 6 rounds; Stage 2 confu; Stage 3 confu"
},
{
"name": "Mustard Powder",
"effect": "Saving Throw DC 22 Fortitude; Maximum Duration 6 rounds; Stage 1 1d6 poi; Stage 2 2d4 poi; Stage 3 2d6 poi"
},
{
"name": "Nethershade",
"effect": "Saving Throw DC 29 Fortitude; Maximum Duration 6 rounds; Stage 1 2d6 void damage and 2d6 poi; Stage 2 3d6 void damage, 2d6 poi; Stage 3 3d6 void damage, 3d6 poi"
},
{
"name": "Nettleweed Residue",
"effect": "Saving Throw DC 27 Fortitude; Maximum Duration 6 minutes; Stage 1 3d6 poi; Stage 2 4d6 poi; Stage 3 6d6 poi"
},
{
"name": "Nightmare Salt",
"effect": "Saving Throw DC 43 Fortitude; Maximum Duration 5 days; Stage 1 frightened 2 once every 1d4 hour; Stage 2 confu; Stage 3 frightened 3, plu; Stage 4 death"
},
{
"name": "Nightmare Vapor",
"effect": "Saving Throw DC 36 Fortitude; Maximum Duration 6 rounds; Stage 1 confu; Stage 2 confu; Stage 3 confu"
},
{
"name": "Numbing Tonic (Minor)",
"effect": "You gain 2 temporary Hit Points."
},
{
"name": "Numbing Tonic (Lesser)",
"effect": "You gain 5 temporary Hit Points."
},
{
"name": "Numbing Tonic (Moderate)",
"effect": "You gain 10 temporary Hit Points."
},
{
"name": "Numbing Tonic (Greater)",
"effect": "You gain 15 temporary Hit Points."
},
{
"name": "Numbing Tonic (Major)",
"effect": "You gain 20 temporary Hit Points."
},
{
"name": "Numbing Tonic (True)",
"effect": "You gain 25 temporary Hit Points."
},
{
"name": "Oblivion Essence",
"effect": "Saving Throw DC 42 Fortitude; Maximum Duration 6 rounds; Stage 1 8d6 poi; Stage 2 10d6 poi; Stage 3 12d6 poi"
},
{
"name": "Pale Fade",
"effect": "Saving Throw DC 42 Fortitude; Maximum Duration 6 rounds; Stage 1 10d6 poi; Stage 2 12d6 poi; Stage 3 15d6 poi"
},
{
"name": "Prey Mutagen (Lesser)",
"effect": "You gain a +10 status bonus to your Speed and gain a +1 circumstance bonus to AC when using Timely Dodge."
},
{
"name": "Prey Mutagen (Moderate)",
"effect": "You gain a +20 status bonus to your Speed and gain a +2 circumstance bonus to AC when using Timely Dodge."
},
{
"name": "Prey Mutagen (Greater)",
"effect": "You gain a +30 status bonus to your Speed and gain a +3 circumstance bonus to AC when using Timely Dodge."
},
{
"name": "Prey Mutagen (Major)",
"effect": "You gain a +40 status bonus to your Speed and gain a +4 circumstance bonus to AC when using Timely Dodge."
},
{
"name": "Pummel-Growth Toxin",
"effect": "Saving Throw DC 32 Fortitude; Maximum Duration 6 rounds; Stage 1 4d6 poi; Stage 2 4d6 poi; Stage 3 4d6 poi"
},
{
"name": "Puppetmaster Extract",
"effect": "Saving Throw DC 26 Fortitude; Maximum Duration 8 rounds; Stage 1 1d12 piercing damage and 1d12 poi; Stage 2 1d12 piercing damage and 2d12 poi; Stage 3 1d12 piercing damage, 2d12 poi; Stage 4 3d12 poi"
},
{
"name": "Reaper's Shadow",
"effect": "Saving Throw DC 30 Fortitude; Maximum Duration 6 rounds; Stage 1 2d12 void damage and doomed 1 for 1 round (1 round); Stage 2 3d12 void damage and doomed 1 for 1 round (1 round); Stage 3 3d12 void damage and doomed 1 (1 round); Stage 4 3d12 void damage and doomed 2 (1 round)"
},
{
"name": "Repulsion Resin",
"effect": "Saving Throw DC 38 Fortitude; Maximum Duration 6 minutes; Stage 1 12d6 mental damage and; Stage 2 16d6 poi; Stage 3 20d6 poi"
},
{
"name": "Sanguine Mutagen (Lesser)",
"effect": "0 Price 3 gp Bulk L The bonus is +1 (or +2 against disease, poison, or fatigued), and the duration is 1 minute."
},
{
"name": "Sanguine Mutagen (Moderate)",
"effect": "0 Price 12 gp Bulk L The bonus is +2 (or +3 against disease, poison, or fatigued), and the duration is 10 minutes."
},
{
"name": "Sanguine Mutagen (Greater)",
"effect": "0 Price 300 gp Bulk L The bonus is +3 (or +4 against disease, poison, or fatigued), and the duration is 1 hour."
},
{
"name": "Sanguine Mutagen (Major)",
"effect": "0 Price 3,000 gp Bulk L The bonus is +4, and the duration is 1 hour."
},
{
"name": "Scarlet Mist",
"effect": "Saving Throw DC 25 Fortitude; Maximum Duration 6 rounds; Stage 1 3d6 poi; Stage 2 3d6 poi; Stage 3 3d6 poi"
},
{
"name": "Serene Mutagen (Lesser)",
"effect": "1 Price 4 gp Bulk L The bonus is +1, or +2 vs."
},
{
"name": "Serene Mutagen (Moderate)",
"effect": "1 Price 12 gp Bulk L The bonus is +2, or +3 vs."
},
{
"name": "Serene Mutagen (Greater)",
"effect": "1 Price 300 gp Bulk L The bonus is +3, or +4 vs."
},
{
"name": "Serene Mutagen (Major)",
"effect": "1 Price 3,000 gp Bulk L The bonus is +4, and the duration is 1 hour."
},
{
"name": "Shadow Essence",
"effect": "Saving Throw DC 29 Fortitude; Maximum Duration 6 rounds; Stage 1 3d6 negative damage and 2d6 poi; Stage 2 3d6 negative damage, 2d6 poi; Stage 3 3d6 negative damage, 2d6 poi"
},
{
"name": "Sight-Theft Grit",
"effect": "Saving Throw DC 28 Fortitude; Maximum Duration 14 hours; Stage 1 dazzled and a 2; Stage 2 dazzled and a 4; Stage 3 blinded (2d6 hour"
},
{
"name": "Silvertongue Mutagen (Lesser)",
"effect": "1 Price 4 gp Bulk L The bonus is +1, and the duration is 1 minute."
},
{
"name": "Silvertongue Mutagen (Moderate)",
"effect": "1 Price 12 gp Bulk L The bonus is +2, and the duration is 10 minutes."
},
{
"name": "Silvertongue Mutagen (Greater)",
"effect": "1 Price 300 gp Bulk L The bonus is +3, and the duration is 1 hour."
},
{
"name": "Silvertongue Mutagen (Major)",
"effect": "1 Price 3,000 gp Bulk L The bonus is +4, and the duration is 1 hour."
},
{
"name": "Skeptic's Elixir (Lesser)",
"effect": "78 Price 4 gp Bulk L The bonus is +1, and the duration is 1 minute."
},
{
"name": "Skeptic's Elixir (Moderate)",
"effect": "78 Price 50 gp Bulk L The bonus is +2, and the duration is 10 minutes."
},
{
"name": "Skeptic's Elixir (Greater)",
"effect": "78 Price 300 gp Bulk L The bonus is +3, and the duration is 1 hour."
},
{
"name": "Sloughing Toxin",
"effect": "Saving Throw DC 25 Fortitude; Maximum Duration 1 hour; Stage 1 1d6 poi; Stage 2 1d6 poi"
},
{
"name": "Slumber Wine",
"effect": "Saving Throw DC 32 Fortitude; Maximum Duration 7 days; Stage 1 uncon; Stage 2 uncon; Stage 3 uncon"
},
{
"name": "Smother Shroud",
"effect": "Saving Throw DC 22 Fortitude; Maximum Duration 10 rounds; Stage 1 2d4 poi; Stage 2 3d4 poi; Stage 3 4d4 poi"
},
{
"name": "Soothing Tonic (Lesser)",
"effect": "You gain fast healing 1."
},
{
"name": "Soothing Tonic (Moderate)",
"effect": "You gain fast healing 3."
},
{
"name": "Soothing Tonic (Greater)",
"effect": "You gain fast healing 5."
},
{
"name": "Soothing Tonic (Major)",
"effect": "You gain fast healing 10."
},
{
"name": "Spear Frog Poison",
"effect": "Saving Throw DC 15 Fortitude; Maximum Duration 6 rounds; Stage 1 1d4 poi; Stage 2 1d6 poi"
},
{
"name": "Spectral Nightshade",
"effect": "Saving Throw DC 33 Fortitude; Maximum Duration 6 minutes; Stage 1 10d6 poi; Stage 2 13d6 poi; Stage 3 15d6 poi"
},
{
"name": "Spell-Eating Pitch",
"effect": "Saving Throw DC 31 Fortitude; Maximum Duration 6 rounds; Stage 1 5d6 poi; Stage 2 6d6 poi; Stage 3 7d6 poi"
},
{
"name": "Spider Root",
"effect": "Saving Throw DC 28 Fortitude; Maximum Duration 6 minutes; Stage 1 3d6 poi; Stage 2 4d6 poi; Stage 3 6d6 poi"
},
{
"name": "Spiderfoot Brew (Lesser)",
"effect": "0 Price 12 gp Bulk L The climb Speed is 15 feet, the item bonus is +1, and the duration is 1 minute."
},
{
"name": "Spiderfoot Brew (Moderate)",
"effect": "0 Price 150 gp Bulk L The climb Speed is 20 feet, the item bonus is +2, and the duration is 10 minutes."
},
{
"name": "Spiderfoot Brew (Greater)",
"effect": "0 Price 2,500 gp Bulk L The climb Speed is 25 feet, the item bonus is +3, and the duration is 1 hour."
},
{
"name": "Spiderfoot Brew (Major)",
"effect": "0 Price 2,500 gp Bulk L The climb speed is 25 feet, the item bonus is +3, and the duration is 1 hour."
},
{
"name": "Stone Body Mutagen (Lesser)",
"effect": "You gain resistance 5 to physical damage (except bludgeoning) and the duration is 10 minutes."
},
{
"name": "Stone Body Mutagen (Moderate)",
"effect": "You gain resistance 5 to physical damage (except bludgeoning) and the duration is 1 hour."
},
{
"name": "Stone Body Mutagen (Greater)",
"effect": "You gain resistance 10 to physical damage (except bludgeoning) and the duration is 1 hour."
},
{
"name": "Stupor Poison",
"effect": "Saving Throw DC 20 Fortitude; Maximum Duration 6 hours; Stage 3 uncon; Stage 4 uncon"
},
{
"name": "Surging Serum (Minor)",
"effect": "The elixir counteracts at 1st-rank and has a +6 counteract modifier."
},
{
"name": "Surging Serum (Lesser)",
"effect": "The elixir counteracts at 2nd-rank and has a +8 counteract modifier."
},
{
"name": "Surging Serum (Moderate)",
"effect": "The elixir counteracts at 4th-rank and has a +14 counteract modifier."
},
{
"name": "Surging Serum (Greater)",
"effect": "The elixir counteracts at 6th-rank and has a +19 counteract modifier."
},
{
"name": "Surging Serum (Major)",
"effect": "The elixir counteracts at 9th-rank and has a +28 counteract modifier."
},
{
"name": "Tangle Root Toxin",
"effect": "Saving Throw DC 26 Fortitude; Maximum Duration 6 minutes; Stage 1 clum; Stage 2 clum; Stage 3 clum"
},
{
"name": "Taster's Folly",
"effect": "Saving Throw DC 21 Fortitude; Maximum Duration 6 minutes; Stage 1 2d4 poi; Stage 2 3d4 poi; Stage 3 4d4 poi"
},
{
"name": "Tatzlwyrm's Gasp",
"effect": "Saving Throw DC 15 Fortitude; Maximum Duration 3 rounds; Stage 2 2d6 poi; Stage 3 4d6 poi"
},
{
"name": "Tears of Death",
"effect": "Saving Throw DC 44 Fortitude; Maximum Duration 10 minutes; Stage 1 20d6 poi; Stage 2 22d6 poi; Stage 3 24d6 poi"
},
{
"name": "Terror Spores",
"effect": "Saving Throw DC 28 Fortitude; Maximum Duration 6 rounds; Stage 1 frightened 2 (1 round); Stage 2 frightened 3 (1 round); Stage 3 frightened 3 and fleeing for 1 round (1 round)"
},
{
"name": "The Dancer's Song",
"effect": "Saving Throw DC 23 Fortitude; Maximum Duration 6 days; Stage 1 2d8 poi; Stage 2 3d8 poi; Stage 3 4d8 poi"
},
{
"name": "Toad Tears",
"effect": "Saving Throw DC 19 Fortitude; Maximum Duration 30 minutes"
},
{
"name": "Toadskin Salve (Greater)",
"effect": "27 Price 55 gp Bulk L The persistent poison damage increases to 2d4, and the resistance increases to 5."
},
{
"name": "Toadskin Salve (Major)",
"effect": "27 Price 225 gp Bulk L The persistent poison damage increases to 3d4, the resistance increases to 8, and the duration increases to up to 1 hour if you don't use the reaction."
},
{
"name": "Toxic Effluence",
"effect": "Saving Throw DC 29 Fortitude; Maximum Duration 6 rounds; Stage 1 3d6 poi; Stage 2 4d6 poi; Stage 3 5d6 poi"
},
{
"name": "Violet Venom",
"effect": "Saving Throw DC 17 Fortitude; Maximum Duration 6 rounds; Stage 1 1d6 poi; Stage 2 1d6 poi; Stage 3 2d6 poi"
},
{
"name": "War Blood Mutagen (Lesser)",
"effect": "77 Price 4 gp Bulk L The item bonus is +1, the DC to remove the weapon is 25, and the duration is 1 minute."
},
{
"name": "War Blood Mutagen (Moderate)",
"effect": "77 Price 12 gp Bulk L The item bonus is +2, the DC to remove the weapon is 30, and the duration is 10 minutes."
},
{
"name": "War Blood Mutagen (Greater)",
"effect": "77 Price 300 gp Bulk L The item bonus is +3, the DC to remove the weapon is 40, and the duration is 1 hour."
},
{
"name": "War Blood Mutagen (Major)",
"effect": "77 Price 3,000 gp Bulk L The item bonus is +4, the DC to remove the weapon is 50, and the duration is 1 hour."
},
{
"name": "Warpwobble Poison",
"effect": "Saving Throw DC 26 Will; Maximum Duration 6 rounds; Stage 1 treat all; Stage 2 treat all; Stage 3 treat all"
},
{
"name": "Weeping Midnight",
"effect": "Saving Throw DC 36 Fortitude; Maximum Duration 6 rounds; Stage 1 6d6 poi; Stage 2 7d6 poi; Stage 3 8d6 poi"
},
{
"name": "Wyvern Poison",
"effect": "Saving Throw DC 26 Fortitude; Maximum Duration 6 rounds; Stage 1 3d6 poi; Stage 2 3d8 poi; Stage 3 3d10 poi"
}
]

View File

@@ -0,0 +1,70 @@
-- CreateEnum
CREATE TYPE "ResearchField" AS ENUM ('BOMBER', 'CHIRURGEON', 'MUTAGENIST', 'TOXICOLOGIST');
-- CreateTable
CREATE TABLE "CharacterAlchemyState" (
"id" TEXT NOT NULL,
"characterId" TEXT NOT NULL,
"researchField" "ResearchField",
"versatileVialsCurrent" INTEGER NOT NULL DEFAULT 0,
"versatileVialsMax" INTEGER NOT NULL DEFAULT 0,
"advancedAlchemyBatch" INTEGER NOT NULL DEFAULT 0,
"advancedAlchemyMax" INTEGER NOT NULL DEFAULT 0,
"lastRestAt" TIMESTAMP(3),
CONSTRAINT "CharacterAlchemyState_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CharacterFormula" (
"id" TEXT NOT NULL,
"characterId" TEXT NOT NULL,
"equipmentId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"nameGerman" TEXT,
"learnedAt" INTEGER NOT NULL DEFAULT 1,
"formulaSource" TEXT,
CONSTRAINT "CharacterFormula_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CharacterPreparedItem" (
"id" TEXT NOT NULL,
"characterId" TEXT NOT NULL,
"equipmentId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"nameGerman" TEXT,
"quantity" INTEGER NOT NULL DEFAULT 1,
"isQuickAlchemy" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CharacterPreparedItem_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "CharacterAlchemyState_characterId_key" ON "CharacterAlchemyState"("characterId");
-- CreateIndex
CREATE INDEX "CharacterFormula_characterId_idx" ON "CharacterFormula"("characterId");
-- CreateIndex
CREATE UNIQUE INDEX "CharacterFormula_characterId_equipmentId_key" ON "CharacterFormula"("characterId", "equipmentId");
-- CreateIndex
CREATE INDEX "CharacterPreparedItem_characterId_idx" ON "CharacterPreparedItem"("characterId");
-- AddForeignKey
ALTER TABLE "CharacterAlchemyState" ADD CONSTRAINT "CharacterAlchemyState_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CharacterFormula" ADD CONSTRAINT "CharacterFormula_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CharacterFormula" ADD CONSTRAINT "CharacterFormula_equipmentId_fkey" FOREIGN KEY ("equipmentId") REFERENCES "Equipment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CharacterPreparedItem" ADD CONSTRAINT "CharacterPreparedItem_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CharacterPreparedItem" ADD CONSTRAINT "CharacterPreparedItem_equipmentId_fkey" FOREIGN KEY ("equipmentId") REFERENCES "Equipment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "CharacterPreparedItem" ADD COLUMN "isInfused" BOOLEAN NOT NULL DEFAULT true;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Equipment" ADD COLUMN "effect" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Translation" ADD COLUMN "germanEffect" TEXT;

View File

@@ -97,6 +97,13 @@ enum TranslationQuality {
LOW
}
enum ResearchField {
BOMBER
CHIRURGEON
MUTAGENIST
TOXICOLOGIST
}
// ==========================================
// USER & AUTH
// ==========================================
@@ -205,6 +212,11 @@ model Character {
resources CharacterResource[]
battleTokens BattleToken[]
documentAccess DocumentAccess[]
// Alchemy
alchemyState CharacterAlchemyState?
formulas CharacterFormula[]
preparedItems CharacterPreparedItem[]
}
model CharacterAbility {
@@ -308,6 +320,56 @@ model CharacterResource {
@@unique([characterId, name])
}
// ==========================================
// ALCHEMY SYSTEM
// ==========================================
model CharacterAlchemyState {
id String @id @default(uuid())
characterId String @unique
researchField ResearchField?
versatileVialsCurrent Int @default(0)
versatileVialsMax Int @default(0)
advancedAlchemyBatch Int @default(0)
advancedAlchemyMax Int @default(0)
lastRestAt DateTime?
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
}
model CharacterFormula {
id String @id @default(uuid())
characterId String
equipmentId String
name String
nameGerman String?
learnedAt Int @default(1)
formulaSource String? // "Pathbuilder Import", "Purchased", "Found"
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
equipment Equipment @relation(fields: [equipmentId], references: [id])
@@unique([characterId, equipmentId])
@@index([characterId])
}
model CharacterPreparedItem {
id String @id @default(uuid())
characterId String
equipmentId String
name String
nameGerman String?
quantity Int @default(1)
isQuickAlchemy Boolean @default(false)
isInfused Boolean @default(true) // Infused items expire on rest, permanent items don't
createdAt DateTime @default(now())
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
equipment Equipment @relation(fields: [equipmentId], references: [id])
@@index([characterId])
}
// ==========================================
// BATTLE SYSTEM
// ==========================================
@@ -558,8 +620,11 @@ model Equipment {
activation String? // "Cast A Spell", "[one-action]", etc.
duration String?
usage String?
effect String? // Specific effect text for item variants (Lesser, Moderate, Greater, Major)
characterItems CharacterItem[]
characterItems CharacterItem[]
formulas CharacterFormula[]
preparedItems CharacterPreparedItem[]
}
model Spell {
@@ -596,6 +661,7 @@ model Translation {
germanName String
germanSummary String?
germanDescription String?
germanEffect String? // Translated effect text for alchemical items
quality TranslationQuality @default(MEDIUM)
translatedBy String @default("claude-api")
createdAt DateTime @default(now())

View File

@@ -55,6 +55,7 @@ interface EquipmentJson {
url: string;
summary: string;
activation?: string;
effect?: string; // Specific effect text for item variants (Lesser, Moderate, Greater, Major)
}
function parseTraits(traitString: string): string[] {
@@ -288,6 +289,7 @@ async function seedEquipment() {
url: item.url || null,
summary: item.summary || null,
activation: item.activation || null,
effect: item.effect || null,
},
create: {
name: item.name,
@@ -298,6 +300,7 @@ async function seedEquipment() {
url: item.url || null,
summary: item.summary || null,
activation: item.activation || null,
effect: item.effect || null,
},
});
created++;

View File

@@ -0,0 +1,205 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { AlchemyService } from './alchemy.service';
import {
UpdateVialsDto,
AddFormulaDto,
DailyPreparationDto,
QuickAlchemyDto,
InitializeAlchemyDto,
CraftAlchemyDto,
} from './dto';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('Character Alchemy')
@ApiBearerAuth()
@Controller('campaigns/:campaignId/characters/:characterId/alchemy')
export class AlchemyController {
constructor(private readonly alchemyService: AlchemyService) {}
@Get()
@ApiOperation({ summary: 'Get full alchemy data for a character' })
@ApiResponse({ status: 200, description: 'Alchemy state, formulas, and prepared items' })
async getAlchemy(
@Param('characterId') characterId: string,
@CurrentUser('id') userId: string,
) {
return this.alchemyService.getAlchemy(characterId, userId);
}
@Post('initialize')
@ApiOperation({ summary: 'Initialize or update alchemy state for a character' })
@ApiResponse({ status: 201, description: 'Alchemy state created/updated' })
async initializeAlchemy(
@Param('characterId') characterId: string,
@Body() dto: InitializeAlchemyDto,
@CurrentUser('id') userId: string,
) {
return this.alchemyService.initializeAlchemy(characterId, dto, userId);
}
// Vials
@Patch('vials')
@ApiOperation({ summary: 'Update versatile vials current count' })
@ApiResponse({ status: 200, description: 'Vials updated' })
async updateVials(
@Param('characterId') characterId: string,
@Body() dto: UpdateVialsDto,
@CurrentUser('id') userId: string,
) {
return this.alchemyService.updateVials(characterId, dto.current, userId);
}
@Post('vials/refill')
@ApiOperation({ summary: 'Refill versatile vials (10 minutes exploration activity)' })
@ApiResponse({ status: 200, description: 'Vials refilled to max' })
async refillVials(
@Param('characterId') characterId: string,
@CurrentUser('id') userId: string,
) {
return this.alchemyService.refillVials(characterId, userId);
}
// Formulas
@Get('formulas')
@ApiOperation({ summary: 'Get all formulas in the formula book' })
@ApiResponse({ status: 200, description: 'List of formulas' })
async getFormulas(
@Param('characterId') characterId: string,
@CurrentUser('id') userId: string,
) {
return this.alchemyService.getFormulas(characterId, userId);
}
@Get('formulas/available')
@ApiOperation({
summary: 'Get all available formula versions up to character level',
description:
'Returns all versions of known formulas that the character can create, ' +
'including upgraded versions up to their current level. ' +
'E.g., knowing "Alchemist\'s Fire (Lesser)" grants access to "(Moderate)" version at level 3+.',
})
@ApiResponse({ status: 200, description: 'List of available equipment with levels' })
async getAvailableFormulas(
@Param('characterId') characterId: string,
@CurrentUser('id') userId: string,
) {
return this.alchemyService.getAvailableFormulas(characterId, userId);
}
@Post('formulas')
@ApiOperation({ summary: 'Add a formula to the formula book' })
@ApiResponse({ status: 201, description: 'Formula added' })
async addFormula(
@Param('characterId') characterId: string,
@Body() dto: AddFormulaDto,
@CurrentUser('id') userId: string,
) {
return this.alchemyService.addFormula(characterId, dto, userId);
}
@Delete('formulas/:formulaId')
@ApiOperation({ summary: 'Remove a formula from the formula book' })
@ApiResponse({ status: 200, description: 'Formula removed' })
async removeFormula(
@Param('characterId') characterId: string,
@Param('formulaId') formulaId: string,
@CurrentUser('id') userId: string,
) {
return this.alchemyService.removeFormula(characterId, formulaId, userId);
}
// Prepared Items
@Get('prepared')
@ApiOperation({ summary: 'Get all prepared/infused items' })
@ApiResponse({ status: 200, description: 'List of prepared items' })
async getPreparedItems(
@Param('characterId') characterId: string,
@CurrentUser('id') userId: string,
) {
return this.alchemyService.getPreparedItems(characterId, userId);
}
@Post('prepare')
@ApiOperation({ summary: 'Daily preparation - create infused items using advanced alchemy' })
@ApiResponse({ status: 201, description: 'Items prepared successfully' })
async dailyPreparation(
@Param('characterId') characterId: string,
@Body() dto: DailyPreparationDto,
@CurrentUser('id') userId: string,
) {
return this.alchemyService.dailyPreparation(characterId, dto, userId);
}
@Post('quick')
@ApiOperation({ summary: 'Quick Alchemy - create an item using 1 versatile vial' })
@ApiResponse({ status: 201, description: 'Item created via quick alchemy' })
async quickAlchemy(
@Param('characterId') characterId: string,
@Body() dto: QuickAlchemyDto,
@CurrentUser('id') userId: string,
) {
return this.alchemyService.quickAlchemy(characterId, dto, userId);
}
@Post('craft')
@ApiOperation({ summary: 'Craft permanent alchemical item (does not expire on rest)' })
@ApiResponse({ status: 201, description: 'Permanent item created' })
async craftAlchemy(
@Param('characterId') characterId: string,
@Body() dto: CraftAlchemyDto,
@CurrentUser('id') userId: string,
) {
return this.alchemyService.craftAlchemy(characterId, dto.equipmentId, dto.quantity, userId);
}
@Patch('prepared/:itemId/consume')
@ApiOperation({ summary: 'Consume/use a prepared item (reduces quantity by 1)' })
@ApiResponse({ status: 200, description: 'Item consumed' })
async consumePreparedItem(
@Param('characterId') characterId: string,
@Param('itemId') itemId: string,
@CurrentUser('id') userId: string,
) {
return this.alchemyService.consumePreparedItem(characterId, itemId, userId);
}
@Delete('prepared/:itemId')
@ApiOperation({ summary: 'Delete a prepared item completely' })
@ApiResponse({ status: 200, description: 'Item deleted' })
async deletePreparedItem(
@Param('characterId') characterId: string,
@Param('itemId') itemId: string,
@CurrentUser('id') userId: string,
) {
return this.alchemyService.deletePreparedItem(characterId, itemId, userId);
}
@Post('formulas/refresh-translations')
@ApiOperation({
summary: 'Refresh translations for all formulas',
description:
'Pre-translates all versions of all known formulas. ' +
'Use this once to populate translations for existing characters.',
})
@ApiResponse({ status: 200, description: 'Translations refreshed' })
async refreshFormulaTranslations(
@Param('characterId') characterId: string,
@CurrentUser('id') userId: string,
) {
return this.alchemyService.refreshFormulaTranslations(characterId, userId);
}
}

View File

@@ -0,0 +1,890 @@
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { TranslationsService } from '../translations/translations.service';
import { CharactersGateway } from './characters.gateway';
import { TranslationType, ResearchField } from '../../generated/prisma/client.js';
import {
AddFormulaDto,
DailyPreparationDto,
QuickAlchemyDto,
InitializeAlchemyDto,
} from './dto';
@Injectable()
export class AlchemyService {
constructor(
private prisma: PrismaService,
private translationsService: TranslationsService,
private charactersGateway: CharactersGateway,
) {}
/**
* Enrich equipment with German translations
*/
private async enrichEquipmentWithTranslations(
equipment: { name: string; summary?: string | null; effect?: string | null } | null | undefined,
): Promise<{ nameGerman?: string; summaryGerman?: string; effectGerman?: string }> {
if (!equipment) {
return {};
}
try {
const translation = await this.translationsService.getTranslation(
TranslationType.EQUIPMENT,
equipment.name,
equipment.summary || undefined,
equipment.effect || undefined,
);
return {
nameGerman: translation.germanName,
summaryGerman: translation.germanDescription,
effectGerman: translation.germanEffect,
};
} catch {
return {};
}
}
// Check if user has access to 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;
}
/**
* Get full alchemy data for a character
*/
async getAlchemy(characterId: string, userId: string) {
await this.checkCharacterAccess(characterId, userId);
const [alchemyState, formulas, preparedItems] = await Promise.all([
this.prisma.characterAlchemyState.findUnique({
where: { characterId },
}),
this.prisma.characterFormula.findMany({
where: { characterId },
include: { equipment: true },
orderBy: [{ learnedAt: 'asc' }, { name: 'asc' }],
}),
this.prisma.characterPreparedItem.findMany({
where: { characterId },
include: { equipment: true },
orderBy: { createdAt: 'desc' },
}),
]);
// Enrich formulas with translations
const enrichedFormulas = await Promise.all(
formulas.map(async (formula) => {
const translations = await this.enrichEquipmentWithTranslations(formula.equipment);
return {
...formula,
equipment: formula.equipment
? { ...formula.equipment, ...translations }
: null,
};
}),
);
// Enrich prepared items with translations
const enrichedPreparedItems = await Promise.all(
preparedItems.map(async (item) => {
const translations = await this.enrichEquipmentWithTranslations(item.equipment);
return {
...item,
equipment: item.equipment
? { ...item.equipment, ...translations }
: null,
};
}),
);
return {
state: alchemyState,
formulas: enrichedFormulas,
preparedItems: enrichedPreparedItems,
};
}
/**
* Initialize alchemy state for a character (usually during import or first setup)
*/
async initializeAlchemy(characterId: string, dto: InitializeAlchemyDto, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
// Check if alchemy state already exists
const existing = await this.prisma.characterAlchemyState.findUnique({
where: { characterId },
});
if (existing) {
// Update existing
const result = await this.prisma.characterAlchemyState.update({
where: { characterId },
data: {
researchField: dto.researchField as ResearchField | undefined,
versatileVialsMax: dto.versatileVialsMax,
versatileVialsCurrent: dto.versatileVialsMax,
advancedAlchemyMax: dto.advancedAlchemyMax,
},
});
this.charactersGateway.broadcastCharacterUpdate(characterId, {
characterId,
type: 'alchemy_state',
data: result,
});
return result;
}
// Create new
const result = await this.prisma.characterAlchemyState.create({
data: {
characterId,
researchField: dto.researchField as ResearchField | undefined,
versatileVialsMax: dto.versatileVialsMax,
versatileVialsCurrent: dto.versatileVialsMax,
advancedAlchemyMax: dto.advancedAlchemyMax,
},
});
this.charactersGateway.broadcastCharacterUpdate(characterId, {
characterId,
type: 'alchemy_state',
data: result,
});
return result;
}
/**
* Update versatile vials current count
*/
async updateVials(characterId: string, current: number, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
const alchemyState = await this.prisma.characterAlchemyState.findUnique({
where: { characterId },
});
if (!alchemyState) {
throw new NotFoundException('Alchemy state not found for this character');
}
const newCurrent = Math.max(0, Math.min(current, alchemyState.versatileVialsMax));
const result = await this.prisma.characterAlchemyState.update({
where: { characterId },
data: { versatileVialsCurrent: newCurrent },
});
this.charactersGateway.broadcastCharacterUpdate(characterId, {
characterId,
type: 'alchemy_vials',
data: { versatileVialsCurrent: result.versatileVialsCurrent },
});
return result;
}
/**
* Refill vials during exploration (10 minutes of activity)
*/
async refillVials(characterId: string, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
const alchemyState = await this.prisma.characterAlchemyState.findUnique({
where: { characterId },
});
if (!alchemyState) {
throw new NotFoundException('Alchemy state not found for this character');
}
const result = await this.prisma.characterAlchemyState.update({
where: { characterId },
data: { versatileVialsCurrent: alchemyState.versatileVialsMax },
});
this.charactersGateway.broadcastCharacterUpdate(characterId, {
characterId,
type: 'alchemy_vials',
data: { versatileVialsCurrent: result.versatileVialsCurrent },
});
return result;
}
/**
* Get formulas for a character
*/
async getFormulas(characterId: string, userId: string) {
await this.checkCharacterAccess(characterId, userId);
const formulas = await this.prisma.characterFormula.findMany({
where: { characterId },
include: { equipment: true },
orderBy: [{ learnedAt: 'asc' }, { name: 'asc' }],
});
// Enrich formulas with translations
return Promise.all(
formulas.map(async (formula) => {
const translations = await this.enrichEquipmentWithTranslations(formula.equipment);
return {
...formula,
equipment: formula.equipment
? { ...formula.equipment, ...translations }
: null,
};
}),
);
}
/**
* Extract the base name from an alchemical item name
* E.g., "Alchemist's Fire (Greater)" -> "Alchemist's Fire"
* "Lesser Elixir of Life" -> "Elixir of Life"
*/
private extractBaseName(name: string): string {
// Remove parenthetical suffix: "Item Name (Lesser)" -> "Item Name"
let baseName = name.replace(/\s*\((Lesser|Minor|Moderate|Greater|Major|True)\)$/i, '').trim();
// Also handle prefix style: "Lesser Item Name" -> "Item Name"
baseName = baseName.replace(/^(Lesser|Minor|Moderate|Greater|Major|True)\s+/i, '').trim();
return baseName;
}
/**
* Check if an item name matches a base name (is a version of that item)
* E.g., "Alchemist's Fire (Greater)" matches base name "Alchemist's Fire"
*/
private isVersionOf(itemName: string, baseName: string): boolean {
const itemBaseName = this.extractBaseName(itemName);
return itemBaseName.toLowerCase() === baseName.toLowerCase();
}
/**
* Get all available formula versions for a character
* This includes upgraded versions of known formulas up to the character's level
* E.g., if character knows "Alchemist's Fire (Lesser)" and is level 5,
* they can also create "Alchemist's Fire (Moderate)" (level 3)
*
* NOTE: This method only reads from the translation cache - it does NOT trigger
* new translations. Translations are pre-loaded when formulas are learned.
*/
async getAvailableFormulas(characterId: string, userId: string) {
const character = await this.checkCharacterAccess(characterId, userId);
// Get all learned formulas
const formulas = await this.prisma.characterFormula.findMany({
where: { characterId },
include: { equipment: true },
});
if (formulas.length === 0) {
return [];
}
// Extract unique base names from known formulas
const baseNames = [...new Set(formulas.map(f => this.extractBaseName(f.name)))];
// Get all alchemical items up to character level
const allAlchemicalItems = await this.prisma.equipment.findMany({
where: {
itemCategory: 'Alchemical Items',
OR: [
{ level: { lte: character.level } },
{ level: null },
],
},
orderBy: [
{ name: 'asc' },
{ level: 'asc' },
],
});
// Filter to only items that are versions of known formulas
const matchingEquipment = allAlchemicalItems.filter(eq =>
baseNames.some(baseName => this.isVersionOf(eq.name, baseName))
);
// Get cached translations for all matching equipment (single DB query, no API calls)
const equipmentNames = matchingEquipment.map(eq => eq.name);
const cachedTranslations = await this.prisma.translation.findMany({
where: {
type: TranslationType.EQUIPMENT,
englishName: { in: equipmentNames },
},
});
const translationMap = new Map(cachedTranslations.map(t => [t.englishName, t]));
// Enrich equipment with cached translations only
const enrichedEquipment = matchingEquipment.map((eq) => {
const cached = translationMap.get(eq.name);
return {
...eq,
nameGerman: cached?.germanName,
summaryGerman: cached?.germanDescription,
effectGerman: cached?.germanEffect,
// Mark if this is a "known" formula (exact match) or an "upgraded" version
isLearned: formulas.some(f => f.equipmentId === eq.id),
};
});
return enrichedEquipment;
}
/**
* Add a formula to the character's formula book
* Also pre-translates ALL versions of this formula (Lesser, Moderate, Greater, Major)
*/
async addFormula(characterId: string, dto: AddFormulaDto, userId: string) {
const character = await this.checkCharacterAccess(characterId, userId, true);
// Verify equipment exists and is an alchemical item
const equipment = await this.prisma.equipment.findUnique({
where: { id: dto.equipmentId },
});
if (!equipment) {
throw new NotFoundException('Equipment not found');
}
// Check level restriction - can only learn formulas up to character level
const equipmentLevel = equipment.level ?? 0;
if (equipmentLevel > character.level) {
throw new BadRequestException(
`Kann Formel nicht lernen: Gegenstand ist Stufe ${equipmentLevel}, Charakter ist Stufe ${character.level}`,
);
}
// Check if formula already exists
const existing = await this.prisma.characterFormula.findUnique({
where: {
characterId_equipmentId: {
characterId,
equipmentId: dto.equipmentId,
},
},
});
if (existing) {
throw new BadRequestException('Formula already exists in formula book');
}
// Pre-translate ALL versions of this formula (Lesser, Moderate, Greater, Major)
// This happens in the background so subsequent loads are fast
const baseName = this.extractBaseName(equipment.name);
this.preTranslateAllVersions(baseName).catch((err) => {
// Log but don't fail the request
console.error('Failed to pre-translate formula versions:', err);
});
// Get translation for this specific item
let nameGerman: string | undefined;
try {
const translation = await this.translationsService.getTranslation(
TranslationType.EQUIPMENT,
equipment.name,
equipment.summary || undefined,
equipment.effect || undefined,
);
nameGerman = translation.germanName;
} catch {
// Translation not available, use original name
}
const result = await this.prisma.characterFormula.create({
data: {
characterId,
equipmentId: dto.equipmentId,
name: equipment.name,
nameGerman,
learnedAt: dto.learnedAt || 1,
formulaSource: dto.formulaSource,
},
include: { equipment: true },
});
this.charactersGateway.broadcastCharacterUpdate(characterId, {
characterId,
type: 'alchemy_formulas',
data: { action: 'add', formula: result },
});
return result;
}
/**
* Pre-translate all versions of a formula (Lesser, Moderate, Greater, Major)
* This is called when a formula is learned to ensure all versions are cached
*/
private async preTranslateAllVersions(baseName: string): Promise<void> {
// Find all alchemical items that are versions of this base formula
const allVersions = await this.prisma.equipment.findMany({
where: {
itemCategory: 'Alchemical Items',
},
});
const matchingVersions = allVersions.filter((eq) =>
this.isVersionOf(eq.name, baseName),
);
if (matchingVersions.length === 0) {
return;
}
// Batch translate all versions
const itemsToTranslate = matchingVersions.map((eq) => ({
englishName: eq.name,
englishDescription: eq.summary || undefined,
englishEffect: eq.effect || undefined,
}));
await this.translationsService.getTranslationsBatch(
TranslationType.EQUIPMENT,
itemsToTranslate,
);
}
/**
* Refresh translations for all formulas of a character
* This pre-translates all versions of all known formulas
* Use this to populate translations for existing characters
*/
async refreshFormulaTranslations(characterId: string, userId: string) {
await this.checkCharacterAccess(characterId, userId);
// Get all learned formulas
const formulas = await this.prisma.characterFormula.findMany({
where: { characterId },
include: { equipment: true },
});
if (formulas.length === 0) {
return { message: 'No formulas to translate', translated: 0 };
}
// Extract unique base names
const baseNames = [...new Set(formulas.map((f) => this.extractBaseName(f.name)))];
// Find all alchemical items that are versions of these base formulas
const allAlchemicalItems = await this.prisma.equipment.findMany({
where: { itemCategory: 'Alchemical Items' },
});
const matchingVersions = allAlchemicalItems.filter((eq) =>
baseNames.some((baseName) => this.isVersionOf(eq.name, baseName)),
);
if (matchingVersions.length === 0) {
return { message: 'No formula versions found', translated: 0 };
}
// Batch translate all versions (including summary AND effect)
const itemsToTranslate = matchingVersions.map((eq) => ({
englishName: eq.name,
englishDescription: eq.summary || undefined,
englishEffect: eq.effect || undefined,
}));
await this.translationsService.getTranslationsBatch(
TranslationType.EQUIPMENT,
itemsToTranslate,
);
return {
message: 'Translations refreshed',
baseFormulas: baseNames.length,
versionsTranslated: matchingVersions.length,
};
}
/**
* Remove a formula from the character's formula book
*/
async removeFormula(characterId: string, formulaId: string, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
const formula = await this.prisma.characterFormula.findUnique({
where: { id: formulaId },
});
if (!formula || formula.characterId !== characterId) {
throw new NotFoundException('Formula not found');
}
await this.prisma.characterFormula.delete({ where: { id: formulaId } });
this.charactersGateway.broadcastCharacterUpdate(characterId, {
characterId,
type: 'alchemy_formulas',
data: { action: 'remove', formulaId },
});
return { message: 'Formula removed' };
}
/**
* Get prepared items for a character
*/
async getPreparedItems(characterId: string, userId: string) {
await this.checkCharacterAccess(characterId, userId);
const items = await this.prisma.characterPreparedItem.findMany({
where: { characterId },
include: { equipment: true },
orderBy: { createdAt: 'desc' },
});
// Enrich prepared items with translations
return Promise.all(
items.map(async (item) => {
const translations = await this.enrichEquipmentWithTranslations(item.equipment);
return {
...item,
equipment: item.equipment
? { ...item.equipment, ...translations }
: null,
};
}),
);
}
/**
* Perform daily preparation - create infused items
* This uses advanced alchemy batch slots
* Now supports auto-upgraded formulas (higher level versions of known formulas)
*/
async dailyPreparation(characterId: string, dto: DailyPreparationDto, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
const alchemyState = await this.prisma.characterAlchemyState.findUnique({
where: { characterId },
});
if (!alchemyState) {
throw new NotFoundException('Alchemy state not found for this character');
}
// Calculate total items to prepare
const totalItems = dto.items.reduce((sum, item) => sum + item.quantity, 0);
// Check if we have enough batch slots
const usedSlots = alchemyState.advancedAlchemyBatch;
const availableSlots = alchemyState.advancedAlchemyMax - usedSlots;
if (totalItems > availableSlots) {
throw new BadRequestException(
`Not enough batch slots. Available: ${availableSlots}, Requested: ${totalItems}`,
);
}
// Get available formulas (includes auto-upgraded versions)
const availableFormulas = await this.getAvailableFormulas(characterId, userId);
const availableEquipmentIds = new Set(availableFormulas.map(f => f.id));
// Verify all requested items are available
for (const item of dto.items) {
if (!availableEquipmentIds.has(item.equipmentId)) {
throw new BadRequestException(`Formula not available for equipment: ${item.equipmentId}`);
}
}
// Create a map of available formulas for quick lookup
const availableFormulasMap = new Map(availableFormulas.map(f => [f.id, f]));
// Create prepared items with equipment included
const preparedItems: Array<
Awaited<ReturnType<typeof this.prisma.characterPreparedItem.create>> & { equipment: any }
> = [];
for (const item of dto.items) {
const formula = availableFormulasMap.get(item.equipmentId)!;
const prepared = await this.prisma.characterPreparedItem.create({
data: {
characterId,
equipmentId: item.equipmentId,
name: formula.name,
nameGerman: formula.nameGerman,
quantity: item.quantity,
isQuickAlchemy: false,
},
include: { equipment: true },
});
preparedItems.push(prepared as any);
}
// Update batch counter
await this.prisma.characterAlchemyState.update({
where: { characterId },
data: { advancedAlchemyBatch: usedSlots + totalItems },
});
// Enrich prepared items with translations
const enrichedPreparedItems = await Promise.all(
preparedItems.map(async (item) => {
const translations = await this.enrichEquipmentWithTranslations(item.equipment);
return {
...item,
equipment: item.equipment
? { ...item.equipment, ...translations }
: null,
};
}),
);
this.charactersGateway.broadcastCharacterUpdate(characterId, {
characterId,
type: 'alchemy_prepared',
data: { action: 'prepare', items: enrichedPreparedItems, batchUsed: totalItems },
});
return { preparedItems: enrichedPreparedItems, batchUsed: totalItems, batchRemaining: availableSlots - totalItems };
}
/**
* Use Quick Alchemy to create an item on the fly
* This costs 1 versatile vial
* Now supports auto-upgraded formulas (higher level versions of known formulas)
*/
async quickAlchemy(characterId: string, dto: QuickAlchemyDto, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
const alchemyState = await this.prisma.characterAlchemyState.findUnique({
where: { characterId },
});
if (!alchemyState) {
throw new NotFoundException('Alchemy state not found for this character');
}
if (alchemyState.versatileVialsCurrent < 1) {
throw new BadRequestException('No versatile vials available');
}
// Get available formulas (includes auto-upgraded versions)
const availableFormulas = await this.getAvailableFormulas(characterId, userId);
const formula = availableFormulas.find(f => f.id === dto.equipmentId);
if (!formula) {
throw new BadRequestException('Formula not available');
}
// Create the quick alchemy item
const prepared = await this.prisma.characterPreparedItem.create({
data: {
characterId,
equipmentId: dto.equipmentId,
name: formula.name,
nameGerman: formula.nameGerman,
quantity: 1,
isQuickAlchemy: true,
},
include: { equipment: true },
});
// Enrich with translations
const translations = await this.enrichEquipmentWithTranslations(prepared.equipment);
const enrichedPrepared = {
...prepared,
equipment: prepared.equipment
? { ...prepared.equipment, ...translations }
: null,
};
// Consume a vial
await this.prisma.characterAlchemyState.update({
where: { characterId },
data: { versatileVialsCurrent: alchemyState.versatileVialsCurrent - 1 },
});
this.charactersGateway.broadcastCharacterUpdate(characterId, {
characterId,
type: 'alchemy_prepared',
data: { action: 'quick_alchemy', item: enrichedPrepared },
});
this.charactersGateway.broadcastCharacterUpdate(characterId, {
characterId,
type: 'alchemy_vials',
data: { versatileVialsCurrent: alchemyState.versatileVialsCurrent - 1 },
});
return enrichedPrepared;
}
/**
* Consume/use a prepared item (reduce quantity or remove)
*/
async consumePreparedItem(characterId: string, itemId: string, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
const item = await this.prisma.characterPreparedItem.findUnique({
where: { id: itemId },
});
if (!item || item.characterId !== characterId) {
throw new NotFoundException('Prepared item not found');
}
if (item.quantity > 1) {
// Reduce quantity
const result = await this.prisma.characterPreparedItem.update({
where: { id: itemId },
data: { quantity: item.quantity - 1 },
include: { equipment: true },
});
// Enrich with translations
const translations = await this.enrichEquipmentWithTranslations(result.equipment);
const enrichedResult = {
...result,
equipment: result.equipment
? { ...result.equipment, ...translations }
: null,
};
this.charactersGateway.broadcastCharacterUpdate(characterId, {
characterId,
type: 'alchemy_prepared',
data: { action: 'update', item: enrichedResult },
});
return enrichedResult;
} else {
// Remove entirely
await this.prisma.characterPreparedItem.delete({ where: { id: itemId } });
this.charactersGateway.broadcastCharacterUpdate(characterId, {
characterId,
type: 'alchemy_prepared',
data: { action: 'remove', itemId },
});
return { message: 'Item consumed' };
}
}
/**
* Delete a prepared item completely
*/
async deletePreparedItem(characterId: string, itemId: string, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
const item = await this.prisma.characterPreparedItem.findUnique({
where: { id: itemId },
});
if (!item || item.characterId !== characterId) {
throw new NotFoundException('Prepared item not found');
}
await this.prisma.characterPreparedItem.delete({ where: { id: itemId } });
this.charactersGateway.broadcastCharacterUpdate(characterId, {
characterId,
type: 'alchemy_prepared',
data: { action: 'remove', itemId },
});
return { message: 'Item removed' };
}
/**
* Craft a permanent (non-infused) alchemical item
* This represents normal alchemy crafting with resources/gold
* Items created this way do NOT expire on rest
*/
async craftAlchemy(
characterId: string,
equipmentId: string,
quantity: number,
userId: string,
) {
await this.checkCharacterAccess(characterId, userId, true);
// Verify equipment exists and is an alchemical item
const equipment = await this.prisma.equipment.findUnique({
where: { id: equipmentId },
});
if (!equipment) {
throw new NotFoundException('Equipment not found');
}
// Translate the name if needed
let nameGerman: string | undefined;
try {
const translation = await this.translationsService.getTranslation(
TranslationType.EQUIPMENT,
equipment.name,
equipment.summary || undefined,
equipment.effect || undefined,
);
nameGerman = translation.germanName;
} catch {
// Translation not available, use original name
}
// Create the permanent (non-infused) item
const prepared = await this.prisma.characterPreparedItem.create({
data: {
characterId,
equipmentId,
name: equipment.name,
nameGerman,
quantity,
isQuickAlchemy: false,
isInfused: false, // This is the key difference - permanent items
},
include: { equipment: true },
});
// Enrich with translations
const translations = await this.enrichEquipmentWithTranslations(prepared.equipment);
const enrichedPrepared = {
...prepared,
equipment: prepared.equipment
? { ...prepared.equipment, ...translations }
: null,
};
this.charactersGateway.broadcastCharacterUpdate(characterId, {
characterId,
type: 'alchemy_prepared',
data: { action: 'craft', item: enrichedPrepared },
});
return enrichedPrepared;
}
}

View File

@@ -301,4 +301,25 @@ export class CharactersController {
) {
return this.charactersService.updateResource(id, resourceName, body.current, userId);
}
// Rest System
@Get(':id/rest/preview')
@ApiOperation({ summary: 'Preview what will happen when resting' })
@ApiResponse({ status: 200, description: 'Rest preview data' })
async getRestPreview(
@Param('id') id: string,
@CurrentUser('id') userId: string,
) {
return this.charactersService.getRestPreview(id, userId);
}
@Post(':id/rest')
@ApiOperation({ summary: 'Perform a full rest (8 hours + daily preparation)' })
@ApiResponse({ status: 200, description: 'Rest completed successfully' })
async performRest(
@Param('id') id: string,
@CurrentUser('id') userId: string,
) {
return this.charactersService.performRest(id, userId);
}
}

View File

@@ -20,14 +20,23 @@ interface AuthenticatedSocket extends Socket {
export interface CharacterUpdatePayload {
characterId: string;
type: 'hp' | 'conditions' | 'item' | 'inventory' | 'money' | 'level' | 'equipment_status';
type: 'hp' | 'conditions' | 'item' | 'inventory' | 'money' | 'level' | 'equipment_status' | 'rest' | 'alchemy_vials' | 'alchemy_formulas' | 'alchemy_prepared' | 'alchemy_state';
data: any;
}
// CORS origins from environment (fallback to common dev ports)
const getCorsOrigins = () => {
const origins = process.env.CORS_ORIGINS;
if (origins) {
return origins.split(',').map(o => o.trim());
}
return ['http://localhost:3000', 'http://localhost:5173', 'http://localhost:5175'];
};
@Injectable()
@WebSocketGateway({
cors: {
origin: ['http://localhost:5173', 'http://localhost:3000'],
origin: getCorsOrigins(),
credentials: true,
},
namespace: '/characters',

View File

@@ -5,6 +5,8 @@ import { CharactersController } from './characters.controller';
import { CharactersService } from './characters.service';
import { CharactersGateway } from './characters.gateway';
import { PathbuilderImportService } from './pathbuilder-import.service';
import { AlchemyController } from './alchemy.controller';
import { AlchemyService } from './alchemy.service';
import { TranslationsModule } from '../translations/translations.module';
@Module({
@@ -18,8 +20,8 @@ import { TranslationsModule } from '../translations/translations.module';
inject: [ConfigService],
}),
],
controllers: [CharactersController],
providers: [CharactersService, CharactersGateway, PathbuilderImportService],
exports: [CharactersService, CharactersGateway, PathbuilderImportService],
controllers: [CharactersController, AlchemyController],
providers: [CharactersService, CharactersGateway, PathbuilderImportService, AlchemyService],
exports: [CharactersService, CharactersGateway, PathbuilderImportService, AlchemyService],
})
export class CharactersModule {}

View File

@@ -20,6 +20,9 @@ import {
UpdateItemDto,
CreateConditionDto,
CreateResourceDto,
RestPreviewDto,
RestResultDto,
ConditionReduced,
} from './dto';
@Injectable()
@@ -114,10 +117,36 @@ export class CharactersService {
});
}
/**
* Enrich equipment with German translations from the Translation table
*/
private async enrichEquipmentWithTranslations(
equipment: { name: string; summary?: string | null; effect?: string | null } | null | undefined,
): Promise<{ nameGerman?: string; summaryGerman?: string; effectGerman?: string }> {
if (!equipment) {
return {};
}
try {
const translation = await this.translationsService.getTranslation(
TranslationType.EQUIPMENT,
equipment.name,
equipment.summary || undefined,
equipment.effect || undefined,
);
return {
nameGerman: translation.germanName,
summaryGerman: translation.germanDescription,
effectGerman: translation.germanEffect,
};
} catch {
return {};
}
}
async findOne(id: string, userId: string) {
const character = await this.checkCharacterAccess(id, userId);
return this.prisma.character.findUnique({
const fullCharacter = await this.prisma.character.findUnique({
where: { id },
include: {
owner: { select: { id: true, username: true, avatarUrl: true } },
@@ -128,13 +157,73 @@ export class CharactersService {
items: {
orderBy: { name: 'asc' },
include: {
equipment: true, // Lade Equipment-Details für Kategorie und Stats
equipment: true,
},
},
conditions: true,
resources: true,
// Alchemy
alchemyState: true,
formulas: {
orderBy: [{ learnedAt: 'asc' }, { name: 'asc' }],
include: { equipment: true },
},
preparedItems: {
orderBy: { createdAt: 'desc' },
include: { equipment: true },
},
},
});
if (!fullCharacter) {
return null;
}
// Enrich items with equipment translations
const enrichedItems = await Promise.all(
fullCharacter.items.map(async (item) => {
const translations = await this.enrichEquipmentWithTranslations(item.equipment);
return {
...item,
equipment: item.equipment
? { ...item.equipment, ...translations }
: null,
};
}),
);
// Enrich formulas with equipment translations
const enrichedFormulas = await Promise.all(
fullCharacter.formulas.map(async (formula) => {
const translations = await this.enrichEquipmentWithTranslations(formula.equipment);
return {
...formula,
equipment: formula.equipment
? { ...formula.equipment, ...translations }
: null,
};
}),
);
// Enrich prepared items with equipment translations
const enrichedPreparedItems = await Promise.all(
fullCharacter.preparedItems.map(async (item) => {
const translations = await this.enrichEquipmentWithTranslations(item.equipment);
return {
...item,
equipment: item.equipment
? { ...item.equipment, ...translations }
: null,
};
}),
);
return {
...fullCharacter,
items: enrichedItems,
formulas: enrichedFormulas,
preparedItems: enrichedPreparedItems,
};
}
async update(id: string, dto: UpdateCharacterDto, userId: string) {
@@ -539,4 +628,228 @@ export class CharactersService {
data: { current: Math.max(0, current) },
});
}
// ==========================================
// REST SYSTEM
// ==========================================
/**
* Preview what will happen when the character rests
* PF2e Rest Rules:
* - HP healing: CON modifier × level (minimum 1 × level)
* - Fatigued condition is removed
* - Doomed condition is reduced by 1
* - Drained condition is reduced by 1
* - All resources are reset to max (spell slots, focus points, etc.)
* - Alchemy: Infused items expire, vials refill
*/
async getRestPreview(characterId: string, userId: string): Promise<RestPreviewDto> {
const character = await this.checkCharacterAccess(characterId, userId);
// Load all character data
const fullCharacter = await this.prisma.character.findUnique({
where: { id: characterId },
include: {
abilities: true,
conditions: true,
resources: true,
alchemyState: true,
preparedItems: true,
},
});
if (!fullCharacter) {
throw new NotFoundException('Character not found');
}
// Calculate CON modifier
const conAbility = fullCharacter.abilities.find((a) => a.ability === 'CON');
const conScore = conAbility?.score || 10;
const conMod = Math.floor((conScore - 10) / 2);
// HP healing: CON mod × level (min 1 × level)
const hpToHeal = Math.max(1, conMod) * fullCharacter.level;
const hpAfterRest = Math.min(fullCharacter.hpMax, fullCharacter.hpCurrent + hpToHeal);
// Conditions to remove (Fatigued)
const conditionsToRemove: string[] = [];
const fatigued = fullCharacter.conditions.find(
(c) => c.name.toLowerCase() === 'fatigued' || c.nameGerman?.toLowerCase() === 'erschöpft',
);
if (fatigued) {
conditionsToRemove.push(fatigued.nameGerman || fatigued.name);
}
// Conditions to reduce (Doomed, Drained)
const conditionsToReduce: ConditionReduced[] = [];
for (const condition of fullCharacter.conditions) {
const nameLower = condition.name.toLowerCase();
const isDoomed = nameLower === 'doomed' || condition.nameGerman?.toLowerCase() === 'verdammt';
const isDrained = nameLower === 'drained' || condition.nameGerman?.toLowerCase() === 'entkräftet';
if ((isDoomed || isDrained) && condition.value && condition.value > 0) {
conditionsToReduce.push({
name: condition.nameGerman || condition.name,
oldValue: condition.value,
newValue: condition.value - 1,
});
}
}
// Resources to reset
const resourcesToReset: string[] = fullCharacter.resources
.filter((r) => r.current < r.max)
.map((r) => r.name);
// Check if character has alchemy to reset
const alchemyReset = !!fullCharacter.alchemyState;
// Count infused items that will expire (only items with isInfused=true)
const infusedItemsCount = fullCharacter.preparedItems
.filter((item) => item.isInfused)
.reduce((sum, item) => sum + item.quantity, 0);
return {
hpToHeal: hpAfterRest - fullCharacter.hpCurrent,
hpAfterRest,
conditionsToRemove,
conditionsToReduce,
resourcesToReset,
alchemyReset,
infusedItemsCount,
};
}
/**
* Perform a full rest for the character
*/
async performRest(characterId: string, userId: string): Promise<RestResultDto> {
const character = await this.checkCharacterAccess(characterId, userId, true);
// Load all character data
const fullCharacter = await this.prisma.character.findUnique({
where: { id: characterId },
include: {
abilities: true,
conditions: true,
resources: true,
alchemyState: true,
preparedItems: true,
},
});
if (!fullCharacter) {
throw new NotFoundException('Character not found');
}
// Calculate CON modifier
const conAbility = fullCharacter.abilities.find((a) => a.ability === 'CON');
const conScore = conAbility?.score || 10;
const conMod = Math.floor((conScore - 10) / 2);
// HP healing
const hpToHeal = Math.max(1, conMod) * fullCharacter.level;
const newHpCurrent = Math.min(fullCharacter.hpMax, fullCharacter.hpCurrent + hpToHeal);
const hpHealed = newHpCurrent - fullCharacter.hpCurrent;
// Track what happened
const conditionsRemoved: string[] = [];
const conditionsReduced: ConditionReduced[] = [];
const resourcesReset: string[] = [];
// Process conditions
for (const condition of fullCharacter.conditions) {
const nameLower = condition.name.toLowerCase();
const isFatigued =
nameLower === 'fatigued' || condition.nameGerman?.toLowerCase() === 'erschöpft';
const isDoomed = nameLower === 'doomed' || condition.nameGerman?.toLowerCase() === 'verdammt';
const isDrained =
nameLower === 'drained' || condition.nameGerman?.toLowerCase() === 'entkräftet';
if (isFatigued) {
// Remove Fatigued
await this.prisma.characterCondition.delete({ where: { id: condition.id } });
conditionsRemoved.push(condition.nameGerman || condition.name);
} else if ((isDoomed || isDrained) && condition.value && condition.value > 0) {
const newValue = condition.value - 1;
if (newValue <= 0) {
// Remove if reduced to 0
await this.prisma.characterCondition.delete({ where: { id: condition.id } });
conditionsRemoved.push(condition.nameGerman || condition.name);
} else {
// Reduce by 1
await this.prisma.characterCondition.update({
where: { id: condition.id },
data: { value: newValue },
});
conditionsReduced.push({
name: condition.nameGerman || condition.name,
oldValue: condition.value,
newValue,
});
}
}
}
// Reset resources to max
for (const resource of fullCharacter.resources) {
if (resource.current < resource.max) {
await this.prisma.characterResource.update({
where: { id: resource.id },
data: { current: resource.max },
});
resourcesReset.push(resource.name);
}
}
// Update HP
await this.prisma.character.update({
where: { id: characterId },
data: { hpCurrent: newHpCurrent },
});
// Reset alchemy if present
let alchemyReset = false;
if (fullCharacter.alchemyState) {
// Delete only infused items (non-infused/permanent items remain)
await this.prisma.characterPreparedItem.deleteMany({
where: { characterId, isInfused: true },
});
// Reset vials to max and reset batch counter
await this.prisma.characterAlchemyState.update({
where: { characterId },
data: {
versatileVialsCurrent: fullCharacter.alchemyState.versatileVialsMax,
advancedAlchemyBatch: 0,
lastRestAt: new Date(),
},
});
alchemyReset = true;
}
// Broadcast the rest event
this.charactersGateway.broadcastCharacterUpdate(characterId, {
characterId,
type: 'rest',
data: {
hpCurrent: newHpCurrent,
hpHealed,
conditionsRemoved,
conditionsReduced,
resourcesReset,
alchemyReset,
},
});
return {
hpHealed,
hpCurrent: newHpCurrent,
conditionsRemoved,
conditionsReduced,
resourcesReset,
alchemyReset,
};
}
}

View File

@@ -0,0 +1,181 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsInt, Min, IsBoolean, IsArray, ValidateNested, IsEnum } from 'class-validator';
import { Type } from 'class-transformer';
// Enums
export enum ResearchFieldDto {
BOMBER = 'BOMBER',
CHIRURGEON = 'CHIRURGEON',
MUTAGENIST = 'MUTAGENIST',
TOXICOLOGIST = 'TOXICOLOGIST',
}
// Request DTOs
export class UpdateVialsDto {
@ApiProperty({ description: 'New current vial count' })
@IsInt()
@Min(0)
current: number;
}
export class AddFormulaDto {
@ApiProperty({ description: 'Equipment ID for the formula' })
@IsString()
equipmentId: string;
@ApiPropertyOptional({ description: 'Level at which the formula was learned', default: 1 })
@IsOptional()
@IsInt()
@Min(1)
learnedAt?: number;
@ApiPropertyOptional({ description: 'Source of the formula (e.g., "Purchased", "Found")' })
@IsOptional()
@IsString()
formulaSource?: string;
}
export class PrepareItemDto {
@ApiProperty({ description: 'Equipment ID of the item to prepare' })
@IsString()
equipmentId: string;
@ApiProperty({ description: 'Number of items to prepare', minimum: 1 })
@IsInt()
@Min(1)
quantity: number;
}
export class DailyPreparationDto {
@ApiProperty({ description: 'Items to prepare during daily preparation', type: [PrepareItemDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => PrepareItemDto)
items: PrepareItemDto[];
}
export class QuickAlchemyDto {
@ApiProperty({ description: 'Equipment ID of the item to create' })
@IsString()
equipmentId: string;
}
export class CraftAlchemyDto {
@ApiProperty({ description: 'Equipment ID of the item to craft' })
@IsString()
equipmentId: string;
@ApiProperty({ description: 'Number of items to craft', minimum: 1 })
@IsInt()
@Min(1)
quantity: number;
}
export class InitializeAlchemyDto {
@ApiPropertyOptional({ description: 'Research field', enum: ResearchFieldDto })
@IsOptional()
@IsEnum(ResearchFieldDto)
researchField?: ResearchFieldDto;
@ApiProperty({ description: 'Maximum versatile vials' })
@IsInt()
@Min(0)
versatileVialsMax: number;
@ApiProperty({ description: 'Maximum advanced alchemy batch size' })
@IsInt()
@Min(0)
advancedAlchemyMax: number;
}
// Response DTOs
export class FormulaResponseDto {
@ApiProperty()
id: string;
@ApiProperty()
equipmentId: string;
@ApiProperty()
name: string;
@ApiPropertyOptional()
nameGerman?: string;
@ApiProperty()
learnedAt: number;
@ApiPropertyOptional()
formulaSource?: string;
@ApiPropertyOptional({ description: 'Equipment details if loaded' })
equipment?: any;
}
export class PreparedItemResponseDto {
@ApiProperty()
id: string;
@ApiProperty()
equipmentId: string;
@ApiProperty()
name: string;
@ApiPropertyOptional()
nameGerman?: string;
@ApiProperty()
quantity: number;
@ApiProperty()
isQuickAlchemy: boolean;
@ApiProperty({ description: 'Whether the item is infused (expires on rest) or permanent' })
isInfused: boolean;
@ApiProperty()
createdAt: string;
@ApiPropertyOptional({ description: 'Equipment details if loaded' })
equipment?: any;
}
export class AlchemyStateResponseDto {
@ApiProperty()
id: string;
@ApiProperty()
characterId: string;
@ApiPropertyOptional({ enum: ResearchFieldDto })
researchField?: ResearchFieldDto;
@ApiProperty()
versatileVialsCurrent: number;
@ApiProperty()
versatileVialsMax: number;
@ApiProperty()
advancedAlchemyBatch: number;
@ApiProperty()
advancedAlchemyMax: number;
@ApiPropertyOptional()
lastRestAt?: string;
}
export class FullAlchemyResponseDto {
@ApiPropertyOptional({ type: AlchemyStateResponseDto })
state?: AlchemyStateResponseDto;
@ApiProperty({ type: [FormulaResponseDto] })
formulas: FormulaResponseDto[];
@ApiProperty({ type: [PreparedItemResponseDto] })
preparedItems: PreparedItemResponseDto[];
}

View File

@@ -1,3 +1,5 @@
export * from './create-character.dto';
export * from './update-character.dto';
export * from './pathbuilder-import.dto';
export * from './rest.dto';
export * from './alchemy.dto';

View File

@@ -0,0 +1,57 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
// Response DTOs for rest functionality
export class ConditionReduced {
@ApiProperty()
name: string;
@ApiProperty()
oldValue: number;
@ApiProperty()
newValue: number;
}
export class RestPreviewDto {
@ApiProperty({ description: 'How much HP will be healed' })
hpToHeal: number;
@ApiProperty({ description: 'New HP after healing' })
hpAfterRest: number;
@ApiProperty({ description: 'Conditions that will be removed (e.g., Fatigued)' })
conditionsToRemove: string[];
@ApiProperty({ description: 'Conditions that will be reduced (e.g., Drained 2 -> 1)' })
conditionsToReduce: ConditionReduced[];
@ApiProperty({ description: 'Resources that will be reset to max' })
resourcesToReset: string[];
@ApiProperty({ description: 'Whether alchemy items will be reset' })
alchemyReset: boolean;
@ApiProperty({ description: 'Number of infused items that will expire' })
infusedItemsCount: number;
}
export class RestResultDto {
@ApiProperty({ description: 'HP healed during rest' })
hpHealed: number;
@ApiProperty({ description: 'New current HP' })
hpCurrent: number;
@ApiProperty({ description: 'Conditions that were removed' })
conditionsRemoved: string[];
@ApiProperty({ description: 'Conditions that were reduced' })
conditionsReduced: ConditionReduced[];
@ApiProperty({ description: 'Resources that were reset' })
resourcesReset: string[];
@ApiProperty({ description: 'Whether alchemy was reset' })
alchemyReset: boolean;
}

View File

@@ -7,9 +7,18 @@ import {
FeatSource,
TranslationType,
CharacterType,
ResearchField,
} from '../../generated/prisma/client.js';
import { PathbuilderJson, PathbuilderBuild } from './dto/pathbuilder-import.dto';
// Research field detection mapping
const RESEARCH_FIELD_FEATS: Record<string, ResearchField> = {
'bomber': ResearchField.BOMBER,
'chirurgeon': ResearchField.CHIRURGEON,
'mutagenist': ResearchField.MUTAGENIST,
'toxicologist': ResearchField.TOXICOLOGIST,
};
// Skill name mappings (English -> German)
const SKILL_TRANSLATIONS: Record<string, string> = {
acrobatics: 'Akrobatik',
@@ -89,6 +98,16 @@ export class PathbuilderImportService {
this.translationsService.getTranslation(TranslationType.BACKGROUND, build.background),
]);
// Convert money to credits (1 pp = 1000 cr, 1 gp = 100 cr, 1 sp = 10 cr, 1 cp = 1 cr)
let credits = 0;
if (build.money) {
credits =
(build.money.pp || 0) * 1000 +
(build.money.gp || 0) * 100 +
(build.money.sp || 0) * 10 +
(build.money.cp || 0);
}
// Create character
const character = await this.prisma.character.create({
data: {
@@ -105,6 +124,7 @@ export class PathbuilderImportService {
classId: build.class,
backgroundId: build.background,
experiencePoints: build.xp || 0,
credits,
pathbuilderData: JSON.parse(JSON.stringify({
...pathbuilderJson,
translations: {
@@ -128,6 +148,11 @@ export class PathbuilderImportService {
this.importResources(character.id, build),
]);
// Import alchemy data if character is an alchemist
if (this.isAlchemist(build)) {
await this.importAlchemy(character.id, build);
}
// Fetch complete character with relations
return this.prisma.character.findUnique({
where: { id: character.id },
@@ -250,28 +275,22 @@ export class PathbuilderImportService {
* Import items (weapons, armor, equipment)
*/
private async importItems(characterId: string, build: PathbuilderBuild) {
const items: Array<{
characterId: string;
// Collect all raw item data from Pathbuilder
const rawItems: Array<{
name: string;
nameGerman?: string;
quantity: number;
bulk: number;
equipped: boolean;
invested: boolean;
notes?: string;
defaultBulk: number;
}> = [];
// 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,
rawItems.push({
name: weapon.name,
quantity: weapon.qty || 1,
bulk: 1, // Default bulk for weapons
defaultBulk: 1,
equipped: true,
invested: false,
notes: `${weapon.die} ${weapon.damageType}${weapon.extraDamage?.length ? ' + ' + weapon.extraDamage.join(', ') : ''}`,
@@ -280,12 +299,10 @@ export class PathbuilderImportService {
// Armor
for (const armor of build.armor || []) {
itemNames.push({ englishName: armor.name });
items.push({
characterId,
rawItems.push({
name: armor.name,
quantity: armor.qty || 1,
bulk: 1, // Default bulk for armor
defaultBulk: 1,
equipped: armor.worn,
invested: false,
});
@@ -297,64 +314,107 @@ export class PathbuilderImportService {
const qty = equip[1] as number;
const invested = equip[2] === 'Invested';
itemNames.push({ englishName: name });
items.push({
characterId,
rawItems.push({
name,
quantity: qty,
bulk: 0,
defaultBulk: 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 (rawItems.length === 0) {
return;
}
if (moneyNote.length > 0) {
items.push({
characterId,
name: 'Münzbeutel',
quantity: 1,
bulk: 0,
equipped: false,
invested: false,
notes: moneyNote.join(', '),
});
// Collect unique item names for database lookup
const uniqueNames = [...new Set(rawItems.map(i => i.name))];
// Find matching Equipment records in the database (case-insensitive)
const equipmentRecords = await this.prisma.equipment.findMany({
where: {
OR: uniqueNames.map(name => ({
name: { equals: name, mode: 'insensitive' as const },
})),
},
});
// Create a map for quick lookup (lowercase name -> equipment)
const equipmentMap = new Map<string, typeof equipmentRecords[0]>();
for (const eq of equipmentRecords) {
equipmentMap.set(eq.name.toLowerCase(), eq);
}
// Log unmatched items for debugging
for (const name of uniqueNames) {
if (!equipmentMap.has(name.toLowerCase())) {
this.logger.warn(`Item not found in equipment database: ${name}`);
}
}
// Batch translate items
if (itemNames.length > 0) {
const translations = await this.translationsService.getTranslationsBatch(
TranslationType.EQUIPMENT,
itemNames,
);
// Batch translate items (including summaries for caching)
const translationRequests = uniqueNames.map(name => {
const equipment = equipmentMap.get(name.toLowerCase());
return {
englishName: name,
englishDescription: equipment?.summary || undefined,
};
});
const translations = await this.translationsService.getTranslationsBatch(
TranslationType.EQUIPMENT,
translationRequests,
);
// Apply translations
for (const item of items) {
if (item.name !== 'Münzbeutel') {
const translation = translations.get(item.name);
if (translation) {
item.nameGerman = translation.germanName;
// Build final items with equipment linkage
const items: Array<{
characterId: string;
equipmentId?: string;
name: string;
nameGerman?: string;
quantity: number;
bulk: number;
equipped: boolean;
invested: boolean;
notes?: string;
}> = [];
for (const rawItem of rawItems) {
const equipment = equipmentMap.get(rawItem.name.toLowerCase());
const translation = translations.get(rawItem.name);
// Parse bulk from equipment (can be "L" for light, number, or "-" for negligible)
let bulk = rawItem.defaultBulk;
if (equipment?.bulk) {
if (equipment.bulk === 'L') {
bulk = 0.1;
} else if (equipment.bulk === '-') {
bulk = 0;
} else {
const parsed = parseFloat(equipment.bulk);
if (!isNaN(parsed)) {
bulk = parsed;
}
}
}
items.push({
characterId,
equipmentId: equipment?.id,
name: rawItem.name,
nameGerman: translation?.germanName,
quantity: rawItem.quantity,
bulk,
equipped: rawItem.equipped,
invested: rawItem.invested,
notes: rawItem.notes,
});
}
if (items.length > 0) {
await this.prisma.characterItem.createMany({
data: items.map(i => ({
...i,
bulk: i.bulk,
})),
data: items,
});
this.logger.debug(`Imported ${items.length} items`);
this.logger.debug(`Imported ${items.length} items (${equipmentRecords.length} matched with database)`);
}
}
@@ -404,4 +464,230 @@ export class PathbuilderImportService {
this.logger.debug(`Imported ${resources.length} resources`);
}
}
// ==========================================
// ALCHEMY IMPORT
// ==========================================
/**
* Check if character is an alchemist
*/
private isAlchemist(build: PathbuilderBuild): boolean {
return build.class.toLowerCase() === 'alchemist';
}
/**
* Detect research field from character feats
*/
private detectResearchField(build: PathbuilderBuild): ResearchField | null {
if (!build.feats || build.feats.length === 0) {
return null;
}
for (const feat of build.feats) {
const featName = (feat[0] as string).toLowerCase();
for (const [key, field] of Object.entries(RESEARCH_FIELD_FEATS)) {
if (featName.includes(key)) {
return field;
}
}
}
return null;
}
/**
* Calculate versatile vials max based on level
* Level 1-4: 2 vials + INT mod
* Level 5-8: 2 vials + INT mod + 2
* Level 9+: 2 vials + INT mod + 4 (and refills hourly)
*/
private calculateVialsMax(build: PathbuilderBuild): number {
const intMod = Math.floor((build.abilities.int - 10) / 2);
const baseVials = 2 + intMod;
if (build.level >= 9) {
return baseVials + 4;
} else if (build.level >= 5) {
return baseVials + 2;
}
return baseVials;
}
/**
* Calculate advanced alchemy batch size
* Equal to INT modifier + level
*/
private calculateAdvancedAlchemyMax(build: PathbuilderBuild): number {
const intMod = Math.floor((build.abilities.int - 10) / 2);
return Math.max(0, intMod + build.level);
}
/**
* Import alchemy data (state and formulas)
*/
private async importAlchemy(characterId: string, build: PathbuilderBuild) {
// Create alchemy state
const researchField = this.detectResearchField(build);
const vialsMax = this.calculateVialsMax(build);
const advancedAlchemyMax = this.calculateAdvancedAlchemyMax(build);
await this.prisma.characterAlchemyState.create({
data: {
characterId,
researchField,
versatileVialsMax: vialsMax,
versatileVialsCurrent: vialsMax,
advancedAlchemyMax,
advancedAlchemyBatch: 0,
},
});
this.logger.log(`Created alchemy state: ${researchField || 'No research field'}, ${vialsMax} vials, ${advancedAlchemyMax} batch size`);
// Import formulas
await this.importFormulas(characterId, build);
}
/**
* Import formulas from Pathbuilder data
* Also pre-translates ALL versions of each formula (Lesser, Moderate, Greater, Major)
*/
private async importFormulas(characterId: string, build: PathbuilderBuild) {
if (!build.formula || build.formula.length === 0) {
return;
}
const formulaNames: string[] = [];
for (const formulaGroup of build.formula) {
if (formulaGroup.known && formulaGroup.known.length > 0) {
formulaNames.push(...formulaGroup.known);
}
}
if (formulaNames.length === 0) {
return;
}
// Find matching equipment in database
const equipmentItems = await this.prisma.equipment.findMany({
where: {
name: { in: formulaNames },
},
});
const equipmentMap = new Map(equipmentItems.map(e => [e.name.toLowerCase(), e]));
// Translate formula names (including summaries AND effects for caching)
const translationRequests = formulaNames.map(name => {
const equipment = equipmentMap.get(name.toLowerCase());
return {
englishName: name,
englishDescription: equipment?.summary || undefined,
englishEffect: equipment?.effect || undefined,
};
});
const translations = await this.translationsService.getTranslationsBatch(
TranslationType.EQUIPMENT,
translationRequests,
);
const formulas: Array<{
characterId: string;
equipmentId: string;
name: string;
nameGerman?: string;
learnedAt: number;
formulaSource: string;
}> = [];
for (const formulaName of formulaNames) {
const equipment = equipmentMap.get(formulaName.toLowerCase());
if (equipment) {
const translation = translations.get(formulaName);
formulas.push({
characterId,
equipmentId: equipment.id,
name: formulaName,
nameGerman: translation?.germanName,
learnedAt: 1, // Default to level 1 from Pathbuilder
formulaSource: 'Pathbuilder Import',
});
} else {
this.logger.warn(`Formula not found in equipment database: ${formulaName}`);
}
}
if (formulas.length > 0) {
await this.prisma.characterFormula.createMany({
data: formulas,
skipDuplicates: true,
});
this.logger.debug(`Imported ${formulas.length} formulas`);
// Pre-translate all versions of each formula in the background
// This ensures fast loading when viewing available formulas later
this.preTranslateAllFormulaVersions(formulaNames).catch(err => {
this.logger.error('Failed to pre-translate formula versions:', err);
});
}
}
/**
* Extract the base name from an alchemical item name
* E.g., "Alchemist's Fire (Greater)" -> "Alchemist's Fire"
* "Lesser Elixir of Life" -> "Elixir of Life"
*/
private extractBaseName(name: string): string {
// Remove parenthetical suffix: "Item Name (Lesser)" -> "Item Name"
let baseName = name.replace(/\s*\((Lesser|Minor|Moderate|Greater|Major|True)\)$/i, '').trim();
// Also handle prefix style: "Lesser Item Name" -> "Item Name"
baseName = baseName.replace(/^(Lesser|Minor|Moderate|Greater|Major|True)\s+/i, '').trim();
return baseName;
}
/**
* Check if an item name matches a base name (is a version of that item)
*/
private isVersionOf(itemName: string, baseName: string): boolean {
const itemBaseName = this.extractBaseName(itemName);
return itemBaseName.toLowerCase() === baseName.toLowerCase();
}
/**
* Pre-translate all versions of the given formulas (Lesser, Moderate, Greater, Major)
*/
private async preTranslateAllFormulaVersions(formulaNames: string[]): Promise<void> {
// Extract unique base names
const baseNames = [...new Set(formulaNames.map(n => this.extractBaseName(n)))];
// Find all alchemical items that are versions of these base formulas
const allAlchemicalItems = await this.prisma.equipment.findMany({
where: { itemCategory: 'Alchemical Items' },
});
const matchingVersions = allAlchemicalItems.filter(eq =>
baseNames.some(baseName => this.isVersionOf(eq.name, baseName))
);
if (matchingVersions.length === 0) {
return;
}
this.logger.debug(`Pre-translating ${matchingVersions.length} formula versions for ${baseNames.length} base formulas`);
// Batch translate all versions (including summary AND effect)
const itemsToTranslate = matchingVersions.map(eq => ({
englishName: eq.name,
englishDescription: eq.summary || undefined,
englishEffect: eq.effect || undefined,
}));
await this.translationsService.getTranslationsBatch(
TranslationType.EQUIPMENT,
itemsToTranslate,
);
this.logger.debug(`Pre-translation complete for formula versions`);
}
}

View File

@@ -6,6 +6,7 @@ export interface TranslationRequest {
type: 'FEAT' | 'ITEM' | 'SPELL' | 'ACTION' | 'SKILL' | 'CLASS' | 'ANCESTRY' | 'HERITAGE' | 'BACKGROUND' | 'CONDITION' | 'TRAIT';
englishName: string;
englishDescription?: string;
englishEffect?: string; // For alchemical items with specific effect text
context?: string;
}
@@ -13,6 +14,7 @@ export interface TranslationResponse {
englishName: string;
germanName: string;
germanDescription?: string;
germanEffect?: string; // Translated effect text
translationQuality: 'HIGH' | 'MEDIUM' | 'LOW';
}
@@ -38,6 +40,7 @@ export class ClaudeService {
englishName: item.englishName,
germanName: item.englishName,
germanDescription: item.englishDescription,
germanEffect: item.englishEffect,
translationQuality: 'LOW' as const,
}));
}
@@ -47,9 +50,15 @@ export class ClaudeService {
}
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 itemsList = items.map((item, i) => {
let entry = `${i + 1}. Type: ${item.type}, Name: "${item.englishName}"`;
if (item.englishDescription) entry += `, Description: "${item.englishDescription}"`;
if (item.englishEffect) entry += `, Effect: "${item.englishEffect}"`;
if (item.context) entry += `, Context: ${item.context}`;
return entry;
}).join('\n');
const hasEffects = items.some(i => i.englishEffect);
const prompt = `Du bist ein Übersetzer für Pathfinder 2e Spielinhalte von Englisch nach Deutsch.
@@ -68,6 +77,28 @@ WICHTIGE ÜBERSETZUNGSREGELN:
- "Background" = "Hintergrund"
- "Condition" = "Zustand"
- "Trait" = "Merkmal"
- "persistent damage" = "andauernder Schaden"
- "splash damage" = "Splitterschaden"
- "item bonus" = "Gegenstandsbonus"
- "slashing damage" = "Hiebschaden"
- "piercing damage" = "Stichschaden"
- "bludgeoning damage" = "Wuchtschaden"
- "bleed damage" = "Blutungsschaden"
- "fire damage" = "Feuerschaden"
- "cold damage" = "Kälteschaden"
- "electricity damage" = "Elektrizitätsschaden"
- "acid damage" = "Säureschaden"
- "poison damage" = "Giftschaden"
- "mental damage" = "Geistiger Schaden"
- "sonic damage" = "Schallschaden"
STUFENBEZEICHNUNGEN (IMMER einheitlich übersetzen):
- "Lesser" = "Schwach" (z.B. "Lesser Alchemist's Fire" = "Schwaches Alchemistenfeuer")
- "Minor" = "Schwach"
- "Moderate" = "Mäßig"
- "Greater" = "Stark"
- "Major" = "Mächtig"
- "True" = "Wahr"
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").
@@ -81,7 +112,8 @@ Antworte NUR mit einem JSON-Array in diesem Format:
{
"englishName": "Original English Name",
"germanName": "Deutscher Name",
"germanDescription": "Deutsche Beschreibung (falls vorhanden)",
"germanDescription": "Deutsche Beschreibung (falls vorhanden)"${hasEffects ? `,
"germanEffect": "Deutscher Effekt-Text (falls vorhanden)"` : ''},
"confidence": 0.9
}
]
@@ -110,6 +142,7 @@ Gib confidence zwischen 0.0 und 1.0 an basierend auf der Übersetzungsqualität.
englishName: string;
germanName: string;
germanDescription?: string;
germanEffect?: string;
confidence: number;
}>;
@@ -117,6 +150,7 @@ Gib confidence zwischen 0.0 und 1.0 an basierend auf der Übersetzungsqualität.
englishName: t.englishName,
germanName: t.germanName,
germanDescription: t.germanDescription,
germanEffect: t.germanEffect,
translationQuality: t.confidence >= 0.8 ? 'HIGH' : t.confidence >= 0.6 ? 'MEDIUM' : 'LOW',
}));
} catch (error) {
@@ -126,6 +160,7 @@ Gib confidence zwischen 0.0 und 1.0 an basierend auf der Übersetzungsqualität.
englishName: item.englishName,
germanName: item.englishName,
germanDescription: item.englishDescription,
germanEffect: item.englishEffect,
translationQuality: 'LOW' as const,
}));
}

View File

@@ -19,18 +19,20 @@ export class TranslationsService {
type: TranslationType,
englishName: string,
englishDescription?: string,
englishEffect?: string,
): Promise<TranslationResponse> {
// Check cache first
const cached = await this.prisma.translation.findUnique({
where: { type_englishName: { type, englishName } },
});
if (cached && this.isValidTranslation(cached, englishDescription)) {
if (cached && this.isValidTranslation(cached, englishDescription, englishEffect)) {
this.logger.debug(`Cache hit for ${type}: ${englishName}`);
return {
englishName: cached.englishName,
germanName: cached.germanName,
germanDescription: cached.germanDescription || undefined,
germanEffect: cached.germanEffect || undefined,
translationQuality: cached.quality,
};
}
@@ -41,6 +43,7 @@ export class TranslationsService {
type: type as TranslationRequest['type'],
englishName,
englishDescription,
englishEffect,
});
// Cache the result
@@ -54,7 +57,7 @@ export class TranslationsService {
*/
async getTranslationsBatch(
type: TranslationType,
items: Array<{ englishName: string; englishDescription?: string }>,
items: Array<{ englishName: string; englishDescription?: string; englishEffect?: string }>,
): Promise<Map<string, TranslationResponse>> {
const result = new Map<string, TranslationResponse>();
@@ -72,15 +75,17 @@ export class TranslationsService {
});
const cachedMap = new Map(cached.map(c => [c.englishName, c]));
const itemsMap = new Map(items.map(i => [i.englishName, i]));
const toTranslate: TranslationRequest[] = [];
for (const item of items) {
const cachedItem = cachedMap.get(item.englishName);
if (cachedItem && this.isValidTranslation(cachedItem, item.englishDescription)) {
if (cachedItem && this.isValidTranslation(cachedItem, item.englishDescription, item.englishEffect)) {
result.set(item.englishName, {
englishName: cachedItem.englishName,
germanName: cachedItem.germanName,
germanDescription: cachedItem.germanDescription || undefined,
germanEffect: cachedItem.germanEffect || undefined,
translationQuality: cachedItem.quality,
});
} else {
@@ -88,6 +93,7 @@ export class TranslationsService {
type: type as TranslationRequest['type'],
englishName: item.englishName,
englishDescription: item.englishDescription,
englishEffect: item.englishEffect,
});
}
}
@@ -125,6 +131,7 @@ export class TranslationsService {
update: {
germanName: translation.germanName,
germanDescription: translation.germanDescription,
germanEffect: translation.germanEffect,
quality: translation.translationQuality as TranslationQuality,
updatedAt: new Date(),
},
@@ -133,6 +140,7 @@ export class TranslationsService {
englishName: translation.englishName,
germanName: translation.germanName,
germanDescription: translation.germanDescription,
germanEffect: translation.germanEffect,
quality: translation.translationQuality as TranslationQuality,
translatedBy: 'claude-api',
},
@@ -153,8 +161,9 @@ export class TranslationsService {
* Check if a cached translation is valid and complete
*/
private isValidTranslation(
cached: { englishName: string; germanName: string; germanDescription?: string | null; quality: string },
cached: { englishName: string; germanName: string; germanDescription?: string | null; germanEffect?: string | null; quality: string },
requestedDescription?: string,
requestedEffect?: string,
): boolean {
// LOW quality means the translation failed or API was unavailable
if (cached.quality === 'LOW') {
@@ -179,6 +188,11 @@ export class TranslationsService {
return false;
}
// If an effect was requested but not cached, re-translate
if (requestedEffect && !cached.germanEffect) {
return false;
}
return true;
}
}