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:
21
CLAUDE.md
21
CLAUDE.md
@@ -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
6
client/.env.example
Normal 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
|
||||
1748
client/src/features/characters/components/alchemy-tab.tsx
Normal file
1748
client/src/features/characters/components/alchemy-tab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
199
client/src/features/characters/components/rest-modal.tsx
Normal file
199
client/src/features/characters/components/rest-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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",
|
||||
|
||||
822
server/prisma/data/scraped-effects.json
Normal file
822
server/prisma/data/scraped-effects.json
Normal 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 can’t reduce frightened value for 1 round (1 round); Stage 2 4d6 mental damage, frightened 2, and can’t 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"
|
||||
}
|
||||
]
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "CharacterPreparedItem" ADD COLUMN "isInfused" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Equipment" ADD COLUMN "effect" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Translation" ADD COLUMN "germanEffect" TEXT;
|
||||
@@ -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())
|
||||
|
||||
@@ -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++;
|
||||
|
||||
205
server/src/modules/characters/alchemy.controller.ts
Normal file
205
server/src/modules/characters/alchemy.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
890
server/src/modules/characters/alchemy.service.ts
Normal file
890
server/src/modules/characters/alchemy.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
181
server/src/modules/characters/dto/alchemy.dto.ts
Normal file
181
server/src/modules/characters/dto/alchemy.dto.ts
Normal 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[];
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
57
server/src/modules/characters/dto/rest.dto.ts
Normal file
57
server/src/modules/characters/dto/rest.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user