diff --git a/CLAUDE.md b/CLAUDE.md index 351a582..0894579 100644 --- a/CLAUDE.md +++ b/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) diff --git a/client/.env.example b/client/.env.example new file mode 100644 index 0000000..b65d9e3 --- /dev/null +++ b/client/.env.example @@ -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 diff --git a/client/src/features/characters/components/alchemy-tab.tsx b/client/src/features/characters/components/alchemy-tab.tsx new file mode 100644 index 0000000..63f4483 --- /dev/null +++ b/client/src/features/characters/components/alchemy-tab.tsx @@ -0,0 +1,1748 @@ +import { useState, useEffect } from 'react'; +import { + FlaskConical, + Plus, + Minus, + RefreshCw, + Beaker, + BookOpen, + Sparkles, + Zap, + Loader2, + Search, + X, + Check, + Trash2, + Hammer, + ChevronDown, + ChevronUp, +} from 'lucide-react'; +import { + Button, + Card, + CardHeader, + CardTitle, + CardContent, +} from '@/shared/components/ui'; +import { ActionIcon } from '@/shared/components/ui/action-icon'; +import { api } from '@/shared/lib/api'; +import type { + Character, + CharacterAlchemyState, + CharacterFormula, + CharacterPreparedItem, + Equipment, + AvailableFormula, +} from '@/shared/types'; + +// Research field translations and colors +const RESEARCH_FIELD_DATA: Record = { + BOMBER: { + german: 'Bomber', + color: 'text-red-400 bg-red-500/20', + description: 'Spezialisiert auf Bomben und explosive Alchemie', + }, + CHIRURGEON: { + german: 'Chirurg', + color: 'text-green-400 bg-green-500/20', + description: 'Spezialisiert auf Heilelixiere und Medizin', + }, + MUTAGENIST: { + german: 'Mutageniker', + color: 'text-blue-400 bg-blue-500/20', + description: 'Spezialisiert auf Mutagene und Körperverbesserung', + }, + TOXICOLOGIST: { + german: 'Toxikologe', + color: 'text-yellow-400 bg-yellow-500/20', + description: 'Spezialisiert auf Gifte und Toxine', + }, +}; + +// Alchemy item subcategory colors +const SUBCATEGORY_COLORS: Record = { + 'Bomb': 'text-red-400 bg-red-500/20', + 'Elixir': 'text-green-400 bg-green-500/20', + 'Mutagen': 'text-blue-400 bg-blue-500/20', + 'Poison': 'text-yellow-400 bg-yellow-500/20', + 'Tool': 'text-gray-400 bg-gray-500/20', + 'Drug': 'text-purple-400 bg-purple-500/20', +}; + +/** + * Extract the base name from an alchemical item name (remove potency suffix) + * Removes any parenthetical suffix at the end of the name + * E.g., "Alchemist's Fire (Greater)" -> "Alchemist's Fire" + * "Alchemistenfeuer (Stark)" -> "Alchemistenfeuer" + */ +function extractBaseName(name: string): string { + // Remove any parenthetical suffix at the end: "Item Name (Anything)" -> "Item Name" + return name.replace(/\s*\([^)]+\)$/, '').trim(); +} + +// PF2e Action Keywords to ActionIcon props mapping +const ACTION_KEYWORDS: Record = { + 'one-action': 1, + 'two-actions': 2, + 'three-actions': 3, + 'reaction': 'reaction', + 'free-action': 'free', +}; + +/** + * Parse activation text and render with proper action icons + * Handles malformed text like "one-action]" (missing opening bracket) + */ +function ActivationText({ text }: { text: string }) { + // Split text by action keywords and render with icons + const parts: React.ReactNode[] = []; + let keyIndex = 0; + + // Create a regex that matches all action keywords (with optional brackets) + const keywordPattern = Object.keys(ACTION_KEYWORDS) + .map(k => `\\[?${k}\\]?`) + .join('|'); + const regex = new RegExp(`(${keywordPattern})`, 'gi'); + + let match; + let lastIndex = 0; + + // Reset regex + regex.lastIndex = 0; + + while ((match = regex.exec(text)) !== null) { + // Add text before the match + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + + // Find which keyword matched (normalize by removing brackets) + const matchedText = match[0].replace(/[\[\]]/g, '').toLowerCase(); + const actionValue = ACTION_KEYWORDS[matchedText]; + + if (actionValue !== undefined) { + parts.push( + + ); + } else { + parts.push(match[0]); + } + + lastIndex = regex.lastIndex; + } + + // Add remaining text + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return {parts}; +} + +interface AlchemyTabProps { + character: Character; + campaignId: string; + onStateUpdate: (state: CharacterAlchemyState) => void; + onFormulasUpdate: (formulas: CharacterFormula[]) => void; + onPreparedItemsUpdate: (items: CharacterPreparedItem[]) => void; +} + +export function AlchemyTab({ + character, + campaignId, + onStateUpdate, + onFormulasUpdate, + onPreparedItemsUpdate, +}: AlchemyTabProps) { + const [showAddFormula, setShowAddFormula] = useState(false); + const [showPrepareItems, setShowPrepareItems] = useState(false); + const [showQuickAlchemy, setShowQuickAlchemy] = useState(false); + const [showCraftAlchemy, setShowCraftAlchemy] = useState(false); + const [selectedFormula, setSelectedFormula] = useState(null); + const [showInfused, setShowInfused] = useState(true); + const [showPermanent, setShowPermanent] = useState(true); + + const alchemyState = character.alchemyState; + const formulas = character.formulas || []; + const preparedItems = character.preparedItems || []; + + // Separate infused and permanent items + const infusedItems = preparedItems.filter((i) => i.isInfused); + const permanentItems = preparedItems.filter((i) => !i.isInfused); + + // Check if character is an alchemist + const isAlchemist = !!alchemyState; + + if (!isAlchemist) { + return ( +
+ + + +

Keine Alchemie verfügbar

+

+ Dieser Charakter ist kein Alchemist und hat keine Alchemie-Fähigkeiten. +

+
+
+
+ ); + } + + const handleUpdateVials = async (newCurrent: number) => { + try { + const result = await api.updateVials(campaignId, character.id, newCurrent); + onStateUpdate(result); + } catch (err) { + console.error('Failed to update vials:', err); + } + }; + + const handleRefillVials = async () => { + try { + const result = await api.refillVials(campaignId, character.id); + onStateUpdate(result); + } catch (err) { + console.error('Failed to refill vials:', err); + } + }; + + const handleDeletePreparedItem = async (itemId: string) => { + try { + await api.deletePreparedItem(campaignId, character.id, itemId); + onPreparedItemsUpdate(preparedItems.filter((i) => i.id !== itemId)); + } catch (err) { + console.error('Failed to delete item:', err); + } + }; + + const researchField = alchemyState.researchField; + const fieldData = researchField ? RESEARCH_FIELD_DATA[researchField] : null; + + return ( +
+ {/* Research Field Card */} + {fieldData && ( + + +
+
+ +
+
+

{fieldData.german}

+

{fieldData.description}

+
+
+
+
+ )} + + {/* Versatile Vials Card */} + + + +
+ + Vielseitige Phiolen +
+ +
+
+ +
+ +
+

+ {alchemyState.versatileVialsCurrent} +

+

von {alchemyState.versatileVialsMax}

+
+ +
+ {/* Progress bar */} +
+
+
+ + + + {/* Advanced Alchemy Batch Card */} + + + + + Fortgeschrittene Alchemie + + + +
+
+

Tägliche Chargen

+

+ {alchemyState.advancedAlchemyBatch} / {alchemyState.advancedAlchemyMax} verwendet +

+
+ +
+ {/* Progress bar */} +
+
+
+ + + + {/* Quick Alchemy and Craft Buttons */} +
+ + +
+ + {/* Infused Items Section */} + + + setShowInfused(!showInfused)} + > +
+ + Infundierte Gegenstände ({infusedItems.length}) +
+ {showInfused ? : } +
+
+ {showInfused && ( + + {infusedItems.length > 0 && ( +

+ + Infundierte Items verfallen beim nächsten Rasten +

+ )} + {infusedItems.length === 0 ? ( +

+ Keine infundierten Gegenstände +

+ ) : ( +
+ {infusedItems.map((item) => ( + handleDeletePreparedItem(item.id)} + /> + ))} +
+ )} +
+ )} +
+ + {/* Permanent Items Section */} + + + setShowPermanent(!showPermanent)} + > +
+ + Hergestellte Gegenstände ({permanentItems.length}) +
+ {showPermanent ? : } +
+
+ {showPermanent && ( + + {permanentItems.length > 0 && ( +

+ + Hergestellte Items bleiben beim Rasten erhalten +

+ )} + {permanentItems.length === 0 ? ( +

+ Keine hergestellten Gegenstände +

+ ) : ( +
+ {permanentItems.map((item) => ( + handleDeletePreparedItem(item.id)} + /> + ))} +
+ )} +
+ )} +
+ + {/* Formula Book */} + + + +
+ + Formelbuch ({formulas.length}) +
+ +
+
+ + {formulas.length === 0 ? ( +

+ Keine Formeln im Formelbuch +

+ ) : ( +
+ {formulas.map((formula) => ( + + ))} +
+ )} +
+
+ + {/* Modals */} + {showAddFormula && ( + f.equipmentId)} + onClose={() => setShowAddFormula(false)} + onAdd={(formula) => { + onFormulasUpdate([...formulas, formula]); + setShowAddFormula(false); + }} + /> + )} + + {showPrepareItems && ( + setShowPrepareItems(false)} + onPrepare={(items) => { + onPreparedItemsUpdate([...preparedItems, ...items]); + // Update batch count in state + const totalPrepared = items.reduce((sum, i) => sum + i.quantity, 0); + onStateUpdate({ + ...alchemyState, + advancedAlchemyBatch: alchemyState.advancedAlchemyBatch + totalPrepared, + }); + setShowPrepareItems(false); + }} + /> + )} + + {showQuickAlchemy && ( + setShowQuickAlchemy(false)} + onCreated={(item) => { + onPreparedItemsUpdate([item, ...preparedItems]); + onStateUpdate({ + ...alchemyState, + versatileVialsCurrent: alchemyState.versatileVialsCurrent - 1, + }); + setShowQuickAlchemy(false); + }} + /> + )} + + {showCraftAlchemy && ( + setShowCraftAlchemy(false)} + onCrafted={(item) => { + onPreparedItemsUpdate([item, ...preparedItems]); + setShowCraftAlchemy(false); + }} + /> + )} + + {selectedFormula && ( + setSelectedFormula(null)} + onRemove={() => { + onFormulasUpdate(formulas.filter((f) => f.id !== selectedFormula.id)); + setSelectedFormula(null); + }} + campaignId={campaignId} + characterId={character.id} + /> + )} +
+ ); +} + +/** + * Parse English effect text and extract damage/healing/bonus values + */ +function parseEffectDamage(effect: string): { dice: string; abbr: string; color: string }[] { + const results: { dice: string; abbr: string; color: string }[] = []; + const splashResults: { dice: string; abbr: string; color: string }[] = []; + + // 1. Healing: "restores 3d6+6 Hit Points" + const healMatch = effect.match(/restores?\s+(\d+d\d+(?:\s*\+\s*\d+)?)\s+Hit\s+Points/i); + if (healMatch) { + results.push({ + dice: healMatch[1].replace(/\s+/g, ''), + abbr: 'Heal', + color: 'bg-green-500/20 text-green-400' + }); + } + + // 2. Item bonus: "bonus is +1" or "+1 item bonus" or "gain a +1 item bonus" + const bonusMatch1 = effect.match(/bonus\s+is\s+\+(\d+)/i); + const bonusMatch2 = effect.match(/\+(\d+)\s+(?:item\s+)?bonus/i); + const bonusValue = bonusMatch1?.[1] || bonusMatch2?.[1]; + if (bonusValue) { + results.push({ + dice: `+${bonusValue}`, + abbr: 'Bonus', + color: 'bg-blue-500/20 text-blue-400' + }); + } + + // If we found healing/bonus, return those + if (results.length > 0) { + return results; + } + + // Type config for damage + const typeConfig: Record = { + 'bludgeoning': { abbr: 'B', color: 'bg-red-500/20 text-red-400' }, + 'slashing': { abbr: 'S', color: 'bg-red-500/20 text-red-400' }, + 'piercing': { abbr: 'P', color: 'bg-red-500/20 text-red-400' }, + 'fire': { abbr: 'F', color: 'bg-orange-500/20 text-orange-400' }, + 'cold': { abbr: 'C', color: 'bg-cyan-500/20 text-cyan-400' }, + 'electricity': { abbr: 'E', color: 'bg-yellow-500/20 text-yellow-400' }, + 'acid': { abbr: 'A', color: 'bg-green-500/20 text-green-400' }, + 'poison': { abbr: 'Po', color: 'bg-purple-500/20 text-purple-400' }, + 'sonic': { abbr: 'So', color: 'bg-pink-500/20 text-pink-400' }, + 'mental': { abbr: 'M', color: 'bg-violet-500/20 text-violet-400' }, + 'force': { abbr: 'Fo', color: 'bg-blue-500/20 text-blue-400' }, + 'bleed': { abbr: 'Blut', color: 'bg-red-700/20 text-red-500' }, + }; + + // 3. Splash damage: "1 slashing splash damage" or "1 splash damage" + const splashPattern = /(\d+d?\d*)\s+(\w+\s+)?splash\s+damage/gi; + let match; + while ((match = splashPattern.exec(effect)) !== null) { + const dice = match[1]; + const splashType = match[2]?.trim().toLowerCase(); + const typeInfo = splashType ? typeConfig[splashType] : null; + splashResults.push({ + dice, + abbr: typeInfo ? `${typeInfo.abbr} Splash` : 'Splash', + color: typeInfo?.color || 'bg-orange-500/20 text-orange-400', + }); + } + + // 4. Regular damage: "1d8 slashing damage", "1 persistent bleed damage" + const damagePattern = /(\d+d\d+|\d+)\s+(persistent\s+)?(\w+)\s+damage/gi; + while ((match = damagePattern.exec(effect)) !== null) { + const dice = match[1]; + const type = match[3].toLowerCase(); + + // Skip if this is splash damage (handled separately) + if (type === 'splash') continue; + + // Check if next word is "splash" - then skip (handled by splash pattern) + const afterMatch = effect.slice(match.index + match[0].length); + if (/^\s*splash/i.test(afterMatch)) continue; + + const config = typeConfig[type] || { abbr: type.charAt(0).toUpperCase(), color: 'bg-red-500/20 text-red-400' }; + results.push({ + dice, + abbr: config.abbr, + color: config.color, + }); + } + + // Splash damage always at the end + return [...results, ...splashResults]; +} + +// Prepared Item Row Component - shows effect directly +interface PreparedItemRowProps { + item: CharacterPreparedItem; + onDelete: () => void; +} + +function PreparedItemRow({ item, onDelete }: PreparedItemRowProps) { + // Always parse the English effect for consistent results + const effectEN = item.equipment?.effect; + const summaryEN = item.equipment?.summary; + const textToParse = effectEN || summaryEN || ''; + const parsedDamages = parseEffectDamage(textToParse); + + // For display fallback, prefer German (prioritize German over English) + const displayText = item.equipment?.effectGerman || item.equipment?.summaryGerman || + item.equipment?.effect || item.equipment?.summary; + + return ( +
+ {/* Header row */} +
+
+ + {item.equipment?.nameGerman || item.nameGerman || item.name} + + {item.equipment?.level !== undefined && item.equipment?.level !== null && ( + + Stufe {item.equipment.level} + + )} + {/* Show creation method for infused items */} + {item.isInfused && ( + item.isQuickAlchemy ? ( + + + Schnell + + ) : ( + + + Vorbereitet + + ) + )} +
+
+ + x{item.quantity} + + +
+
+ + {/* Effect display */} + {parsedDamages.length > 0 ? ( +
+ {parsedDamages.map((dmg, i) => ( + + {dmg.dice} + {dmg.abbr} + + ))} +
+ ) : displayText ? ( +

{displayText}

+ ) : null} +
+ ); +} + +// Formula Detail Modal +interface FormulaDetailModalProps { + formula: CharacterFormula; + onClose: () => void; + onRemove: () => void; + campaignId: string; + characterId: string; +} + +function FormulaDetailModal({ + formula, + onClose, + onRemove, + campaignId, + characterId, +}: FormulaDetailModalProps) { + const [isRemoving, setIsRemoving] = useState(false); + const [isLoadingVariants, setIsLoadingVariants] = useState(true); + const [variants, setVariants] = useState([]); + const [selectedVariantId, setSelectedVariantId] = useState(null); + const [activeTab, setActiveTab] = useState<'info' | 'effects'>('info'); + + const equipment = formula.equipment; + const baseName = extractBaseName(formula.name); + + // Load all variants of this formula + useEffect(() => { + loadVariants(); + }, []); + + const loadVariants = async () => { + setIsLoadingVariants(true); + try { + const data = await api.getAvailableFormulas(campaignId, characterId); + console.log('[FormulaDetail] API returned', data.length, 'available formulas'); + console.log('[FormulaDetail] Looking for baseName:', baseName); + // Filter to only variants of this formula + const formulaVariants = data.filter((f: AvailableFormula) => + extractBaseName(f.name) === baseName + ); + console.log('[FormulaDetail] Found', formulaVariants.length, 'variants:', formulaVariants.map((f: AvailableFormula) => `${f.name} (Lvl ${f.level})`)); + // Sort by level + formulaVariants.sort((a: AvailableFormula, b: AvailableFormula) => (a.level ?? 0) - (b.level ?? 0)); + setVariants(formulaVariants); + // Default to the current formula's equipment + if (equipment) { + setSelectedVariantId(equipment.id); + } else if (formulaVariants.length > 0) { + setSelectedVariantId(formulaVariants[0].id); + } + } catch (err) { + console.error('Failed to load variants:', err); + } finally { + setIsLoadingVariants(false); + } + }; + + const selectedVariant = variants.find(v => v.id === selectedVariantId) || null; + const displayEquipment = selectedVariant || equipment; + + const subcategoryColor = displayEquipment?.itemSubcategory + ? SUBCATEGORY_COLORS[displayEquipment.itemSubcategory] || 'text-text-muted bg-bg-tertiary' + : ''; + + const handleRemove = async () => { + setIsRemoving(true); + try { + await api.removeFormula(campaignId, characterId, formula.id); + onRemove(); + } catch (err) { + console.error('Failed to remove formula:', err); + } finally { + setIsRemoving(false); + } + }; + + return ( +
+ e.stopPropagation()}> +
+
+
+

+ {extractBaseName(displayEquipment?.nameGerman || formula.nameGerman || formula.name)} +

+ {(displayEquipment?.nameGerman || formula.nameGerman) && ( +

{baseName}

+ )} +
+ +
+
+ {/* Tab Navigation */} + {!isLoadingVariants && variants.length > 1 && ( +
+
+ + +
+
+ )} + +
+ {/* Effects Tab - Show all variant effects */} + {activeTab === 'effects' && !isLoadingVariants && variants.length > 1 ? ( +
+

+ Vergleiche die Effekte aller verfügbaren Stufenvarianten: +

+ {variants.map((variant) => { + const isOriginal = variant.id === equipment?.id; + const potencyLabel = variant.nameGerman?.match(/\(([^)]+)\)$/)?.[1] || + variant.name.match(/\(([^)]+)\)$/)?.[1] || + `Stufe ${variant.level ?? 0}`; + return ( +
+
+
+ + {potencyLabel} + + + Stufe {variant.level ?? 0} + + {isOriginal && ( + + Gelernt + + )} +
+ {variant.price !== undefined && variant.price !== null && ( + + {variant.price} Gp + + )} +
+ {(variant.effectGerman || variant.effect) ? ( +

+ {variant.effectGerman || variant.effect} +

+ ) : ( +

+ Kein spezifischer Effekt +

+ )} +
+ ); + })} +
+ ) : ( + <> + {/* Info Tab - Original detail view */} + {/* Formula source badge */} +
+ + Formelbuch + + {formula.formulaSource && ( + + {formula.formulaSource} + + )} +
+ + {/* Variant Selector */} + {!isLoadingVariants && variants.length > 1 && ( +
+

Stufenvarianten

+
+ {variants.map((variant) => { + const isSelected = variant.id === selectedVariantId; + const isOriginal = variant.id === equipment?.id; + return ( + + ); + })} +
+

* = Gelernte Version

+
+ )} + + {isLoadingVariants ? ( +
+ +
+ ) : displayEquipment ? ( +
+ {/* Basic info */} +
+ {displayEquipment.level !== undefined && displayEquipment.level !== null && ( + + Stufe {displayEquipment.level} + + )} + {displayEquipment.itemSubcategory && ( + + {displayEquipment.itemSubcategory} + + )} + {displayEquipment.price !== undefined && displayEquipment.price !== null && ( + + {displayEquipment.price} Gp + + )} + {displayEquipment.bulk && ( + + Gewicht: {displayEquipment.bulk} + + )} +
+ + {/* Traits */} + {displayEquipment.traits && displayEquipment.traits.length > 0 && ( +
+

Eigenschaften

+
+ {displayEquipment.traits.map((trait, i) => ( + + {trait} + + ))} +
+
+ )} + + {/* Activation */} + {displayEquipment.activation && ( +
+

Aktivierung

+

+
+ )} + + {/* Duration */} + {displayEquipment.duration && ( +
+

Dauer

+

{displayEquipment.duration}

+
+ )} + + {/* Usage */} + {displayEquipment.usage && ( +
+

Verwendung

+

{displayEquipment.usage}

+
+ )} + + {/* Effect - specific to this variant (prefer German translation) */} + {(displayEquipment.effectGerman || displayEquipment.effect) && ( +
+

Effekt

+

{displayEquipment.effectGerman || displayEquipment.effect}

+
+ )} + + {/* Summary/Description */} + {(displayEquipment.summaryGerman || displayEquipment.summary) && ( +
+

Beschreibung

+

+ {displayEquipment.summaryGerman || displayEquipment.summary} +

+
+ )} + + {/* Link to Archives of Nethys */} + {displayEquipment.url && ( + + Mehr auf Archives of Nethys + + )} +
+ ) : ( +

+ Keine Details verfügbar +

+ )} + + )} +
+ + {/* Actions */} +
+ +
+
+
+ ); +} + +// Add Formula Modal - Now browsable with level filtering +interface AddFormulaModalProps { + campaignId: string; + characterId: string; + characterLevel: number; + existingFormulaIds: string[]; + onClose: () => void; + onAdd: (formula: CharacterFormula) => void; +} + +function AddFormulaModal({ + campaignId, + characterId, + characterLevel, + existingFormulaIds, + onClose, + onAdd, +}: AddFormulaModalProps) { + const [search, setSearch] = useState(''); + const [results, setResults] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isAdding, setIsAdding] = useState(false); + const [addError, setAddError] = useState(null); + + // Load initial results on mount + useEffect(() => { + loadItems(''); + }, []); + + const loadItems = async (query: string) => { + setIsLoading(true); + try { + // Search for alchemical items up to character level + const data = await api.searchEquipment({ + query: query, + category: 'Alchemical Items', + maxLevel: characterLevel, + limit: 50, + }); + // Filter out already known formulas + const filtered = data.items.filter((item: Equipment) => !existingFormulaIds.includes(item.id)); + setResults(filtered); + } catch (err) { + console.error('Failed to search equipment:', err); + } finally { + setIsLoading(false); + } + }; + + const handleSearch = () => { + loadItems(search); + }; + + const handleAdd = async (equipment: Equipment) => { + setIsAdding(true); + setAddError(null); + try { + const formula = await api.addFormula(campaignId, characterId, { + equipmentId: equipment.id, + formulaSource: 'Purchased', + }); + onAdd(formula); + } catch (err: any) { + const message = err.response?.data?.message || 'Fehler beim Hinzufügen der Formel'; + setAddError(message); + console.error('Failed to add formula:', err); + } finally { + setIsAdding(false); + } + }; + + return ( +
+ e.stopPropagation()}> +
+
+

Formel hinzufügen

+ +
+

+ Zeigt Gegenstände bis Stufe {characterLevel} (Charakterstufe) +

+
+
+ {addError && ( +
+

{addError}

+
+ )} +
+ setSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + placeholder="Alchemistischen Gegenstand suchen..." + className="flex-1 px-3 py-2 rounded-lg bg-bg-secondary border border-border text-text-primary placeholder:text-text-muted focus:outline-none focus:border-primary-500" + /> + +
+
+
+ {isLoading ? ( +
+ +
+ ) : results.length === 0 ? ( +

+ Keine Ergebnisse gefunden +

+ ) : ( +
+ {results.map((item) => { + const subcategoryColor = item.itemSubcategory + ? SUBCATEGORY_COLORS[item.itemSubcategory] || 'text-text-muted' + : ''; + return ( +
+
+

+ {item.nameGerman || item.name} +

+
+ {item.level !== undefined && item.level !== null && ( + + Lvl {item.level} + + )} + {item.itemSubcategory && ( + + {item.itemSubcategory} + + )} +
+
+ +
+ ); + })} +
+ )} +
+
+
+ ); +} + +// Prepare Items Modal - Now uses available formulas (includes upgraded versions) +interface PrepareItemsModalProps { + campaignId: string; + characterId: string; + availableSlots: number; + onClose: () => void; + onPrepare: (items: CharacterPreparedItem[]) => void; +} + +function PrepareItemsModal({ + campaignId, + characterId, + availableSlots, + onClose, + onPrepare, +}: PrepareItemsModalProps) { + const [selections, setSelections] = useState>({}); + const [isLoading, setIsLoading] = useState(true); + const [isPreparing, setIsPreparing] = useState(false); + const [availableFormulas, setAvailableFormulas] = useState([]); + + const totalSelected = Object.values(selections).reduce((sum, qty) => sum + qty, 0); + + // Load available formulas on mount + useEffect(() => { + loadAvailableFormulas(); + }, []); + + const loadAvailableFormulas = async () => { + setIsLoading(true); + try { + const data = await api.getAvailableFormulas(campaignId, characterId); + setAvailableFormulas(data); + } catch (err) { + console.error('Failed to load available formulas:', err); + } finally { + setIsLoading(false); + } + }; + + const handleQuantityChange = (equipmentId: string, delta: number) => { + setSelections((prev) => { + const current = prev[equipmentId] || 0; + const newValue = Math.max(0, current + delta); + if (newValue === 0) { + const { [equipmentId]: _, ...rest } = prev; + return rest; + } + return { ...prev, [equipmentId]: newValue }; + }); + }; + + const handlePrepare = async () => { + const items = Object.entries(selections) + .filter(([_, qty]) => qty > 0) + .map(([equipmentId, qty]) => ({ + equipmentId, + quantity: qty, + })); + + if (items.length === 0) return; + + setIsPreparing(true); + try { + const result = await api.dailyPreparation(campaignId, characterId, items); + onPrepare(result.preparedItems); + } catch (err) { + console.error('Failed to prepare items:', err); + } finally { + setIsPreparing(false); + } + }; + + return ( +
+ e.stopPropagation()}> +
+

Tägliche Vorbereitung

+

+ Verfügbare Chargen: {availableSlots - totalSelected} / {availableSlots} +

+

+ Items werden als infundiert erstellt (verfallen beim Rasten) +

+

+ Zeigt alle Versionen bekannter Formeln bis zu deiner Stufe +

+
+
+ {isLoading ? ( +
+ +
+ ) : availableFormulas.length === 0 ? ( +

+ Keine Formeln verfügbar +

+ ) : ( +
+ {availableFormulas.map((formula) => ( +
+
+
+

+ {formula.nameGerman || formula.name} +

+ {!formula.isLearned && ( + + Upgrade + + )} +
+
+ {formula.level !== undefined && formula.level !== null && ( + + Lvl {formula.level} + + )} + {formula.itemSubcategory && ( + + {formula.itemSubcategory} + + )} +
+
+
+ + + {selections[formula.id] || 0} + + +
+
+ ))} +
+ )} +
+
+ + +
+
+
+ ); +} + +// Quick Alchemy Modal - Now uses available formulas (includes upgraded versions) +interface QuickAlchemyModalProps { + campaignId: string; + characterId: string; + onClose: () => void; + onCreated: (item: CharacterPreparedItem) => void; +} + +function QuickAlchemyModal({ + campaignId, + characterId, + onClose, + onCreated, +}: QuickAlchemyModalProps) { + const [isLoading, setIsLoading] = useState(true); + const [isCreating, setIsCreating] = useState(false); + const [search, setSearch] = useState(''); + const [availableFormulas, setAvailableFormulas] = useState([]); + + // Load available formulas on mount + useEffect(() => { + loadAvailableFormulas(); + }, []); + + const loadAvailableFormulas = async () => { + setIsLoading(true); + try { + const data = await api.getAvailableFormulas(campaignId, characterId); + setAvailableFormulas(data); + } catch (err) { + console.error('Failed to load available formulas:', err); + } finally { + setIsLoading(false); + } + }; + + const filteredFormulas = availableFormulas.filter((f) => + (f.nameGerman || f.name).toLowerCase().includes(search.toLowerCase()) + ); + + const handleCreate = async (formula: AvailableFormula) => { + setIsCreating(true); + try { + const item = await api.quickAlchemy(campaignId, characterId, formula.id); + onCreated(item); + } catch (err) { + console.error('Failed to create item:', err); + } finally { + setIsCreating(false); + } + }; + + return ( +
+ e.stopPropagation()}> +
+
+

Schnelle Alchemie

+ +
+

+ Verbrauche 1 Phiole, um sofort einen Gegenstand herzustellen +

+

+ Item wird als infundiert erstellt (verfällt beim Rasten) +

+

+ Zeigt alle Versionen bekannter Formeln bis zu deiner Stufe +

+
+
+ setSearch(e.target.value)} + placeholder="Formel suchen..." + className="w-full px-3 py-2 rounded-lg bg-bg-secondary border border-border text-text-primary placeholder:text-text-muted focus:outline-none focus:border-primary-500" + /> +
+
+ {isLoading ? ( +
+ +
+ ) : filteredFormulas.length === 0 ? ( +

+ {availableFormulas.length === 0 ? 'Keine Formeln verfügbar' : 'Keine Treffer'} +

+ ) : ( +
+ {filteredFormulas.map((formula) => ( + + ))} +
+ )} +
+
+
+ ); +} + +// Craft Alchemy Modal - For permanent items +interface CraftAlchemyModalProps { + campaignId: string; + characterId: string; + formulas: CharacterFormula[]; + onClose: () => void; + onCrafted: (item: CharacterPreparedItem) => void; +} + +function CraftAlchemyModal({ + campaignId, + characterId, + formulas, + onClose, + onCrafted, +}: CraftAlchemyModalProps) { + const [search, setSearch] = useState(''); + const [isCrafting, setIsCrafting] = useState(false); + const [selectedItem, setSelectedItem] = useState(null); + const [quantity, setQuantity] = useState(1); + + // Filter formulas by search + const filteredFormulas = formulas.filter((formula) => { + if (!search) return true; + const searchLower = search.toLowerCase(); + const name = (formula.equipment?.nameGerman || formula.nameGerman || formula.name).toLowerCase(); + return name.includes(searchLower); + }); + + const handleCraft = async () => { + if (!selectedItem?.equipmentId) return; + setIsCrafting(true); + try { + const item = await api.craftAlchemy(campaignId, characterId, selectedItem.equipmentId, quantity); + onCrafted(item); + } catch (err) { + console.error('Failed to craft item:', err); + } finally { + setIsCrafting(false); + } + }; + + return ( +
+ e.stopPropagation()}> +
+
+

Normale Alchemie

+ +
+

+ Stelle einen alchemischen Gegenstand unter Aufwendung von Ressourcen her +

+

+ Item wird als hergestellt erstellt (bleibt beim Rasten erhalten) +

+
+ + {!selectedItem ? ( + <> +
+ setSearch(e.target.value)} + placeholder="Formel suchen..." + className="w-full px-3 py-2 rounded-lg bg-bg-secondary border border-border text-text-primary placeholder:text-text-muted focus:outline-none focus:border-primary-500" + /> +
+
+ {formulas.length === 0 ? ( +

+ Keine Formeln im Formelbuch +

+ ) : filteredFormulas.length === 0 ? ( +

+ Keine passenden Formeln gefunden +

+ ) : ( +
+ {filteredFormulas.map((formula) => ( + + ))} +
+ )} +
+ + ) : ( +
+
+
+

+ {selectedItem.equipment?.nameGerman || selectedItem.nameGerman || selectedItem.name} +

+ +
+
+ {selectedItem.equipment?.level !== undefined && selectedItem.equipment?.level !== null && ( + + Stufe {selectedItem.equipment.level} + + )} + {selectedItem.equipment?.itemSubcategory && ( + + {selectedItem.equipment.itemSubcategory} + + )} +
+
+ +
+ +
+

{quantity}

+

Menge

+
+ +
+ +
+ + +
+
+ )} +
+
+ ); +} diff --git a/client/src/features/characters/components/character-sheet-page.tsx b/client/src/features/characters/components/character-sheet-page.tsx index ff9fcbd..ce58e80 100644 --- a/client/src/features/characters/components/character-sheet-page.tsx +++ b/client/src/features/characters/components/character-sheet-page.tsx @@ -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(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() {
+ + {/* Rest Button */} +
); }; @@ -1323,69 +1430,24 @@ export function CharacterSheetPage() { ); - const renderAlchemyTab = () => ( -
- - - - - Alchemie-Ressourcen - - - -
- {character.resources.filter(r => r.name.toLowerCase().includes('vial') || r.name.toLowerCase().includes('alchemy')).length === 0 ? ( -

- Keine Alchemie-Ressourcen verfügbar -

- ) : ( - character.resources.filter(r => r.name.toLowerCase().includes('vial') || r.name.toLowerCase().includes('alchemy')).map((resource) => ( -
- {resource.name} -
- - - {resource.current} / {resource.max} - - -
-
- )) - )} -
-
-
- - {/* Alchemical Items from Inventory */} - - - Alchemistische Gegenstände - - - {character.items.filter(i => i.name.toLowerCase().includes('bomb') || i.name.toLowerCase().includes('elixir') || i.name.toLowerCase().includes('mutagen')).length === 0 ? ( -

- Keine alchemistischen Gegenstände -

- ) : ( -
- {character.items.filter(i => i.name.toLowerCase().includes('bomb') || i.name.toLowerCase().includes('elixir') || i.name.toLowerCase().includes('mutagen')).map((item) => ( -
- - {item.nameGerman || item.name} - {item.quantity > 1 && ` (×${item.quantity})`} - -
- ))} -
- )} -
-
-
- ); + const renderAlchemyTab = () => { + if (!campaignId) return null; + return ( + { + 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 ; @@ -1527,6 +1589,17 @@ export function CharacterSheetPage() { onRemove={() => handleRemoveFeat(selectedFeat.id)} /> )} + {showRestModal && campaignId && ( + setShowRestModal(false)} + onRestComplete={(result) => { + // The WebSocket will handle the state update + console.log('Rest complete:', result); + }} + /> + )} ); } diff --git a/client/src/features/characters/components/rest-modal.tsx b/client/src/features/characters/components/rest-modal.tsx new file mode 100644 index 0000000..96667c2 --- /dev/null +++ b/client/src/features/characters/components/rest-modal.tsx @@ -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(null); + const [isLoading, setIsLoading] = useState(true); + const [isResting, setIsResting] = useState(false); + const [error, setError] = useState(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 ( +
+ e.stopPropagation()}> +
+
+
+
+ +
+
+

Rasten

+

8 Stunden + Tägliche Vorbereitung

+
+
+ +
+
+ + + {isLoading ? ( +
+ +
+ ) : error ? ( +
{error}
+ ) : preview ? ( +
+ {/* HP Healing */} + {preview.hpToHeal > 0 && ( +
+ +
+

HP-Heilung

+

+ +{preview.hpToHeal} HP (auf {preview.hpAfterRest}) +

+
+
+ )} + + {/* Conditions to Remove */} + {preview.conditionsToRemove.length > 0 && ( +
+ +
+

Zustände entfernen

+
+ {preview.conditionsToRemove.map((c, i) => ( + + {c} + + ))} +
+
+
+ )} + + {/* Conditions to Reduce */} + {preview.conditionsToReduce.length > 0 && ( +
+ +
+

Zustände reduzieren

+
+ {preview.conditionsToReduce.map((c, i) => ( + + {c.name} {c.oldValue} → {c.newValue} + + ))} +
+
+
+ )} + + {/* Resources to Reset */} + {preview.resourcesToReset.length > 0 && ( +
+ +
+

Ressourcen auffüllen

+
+ {preview.resourcesToReset.map((r, i) => ( + + {r} + + ))} +
+
+
+ )} + + {/* Alchemy Reset */} + {preview.alchemyReset && ( +
+ +
+

Alchemie zurücksetzen

+

+ {preview.infusedItemsCount > 0 + ? `${preview.infusedItemsCount} infundierte Items verfallen, Phiolen werden aufgefüllt` + : 'Phiolen werden aufgefüllt'} +

+
+
+ )} + + {/* Nothing to do */} + {preview.hpToHeal === 0 && + preview.conditionsToRemove.length === 0 && + preview.conditionsToReduce.length === 0 && + preview.resourcesToReset.length === 0 && + !preview.alchemyReset && ( +
+

Dein Charakter ist bereits vollständig erholt.

+
+ )} +
+ ) : null} + + {/* Action Buttons */} +
+ + +
+
+
+
+ ); +} diff --git a/client/src/shared/hooks/use-character-socket.ts b/client/src/shared/hooks/use-character-socket.ts index 98ca944..814fd9f 100644 --- a/client/src/shared/hooks/use-character-socket.ts +++ b/client/src/shared/hooks/use-character-socket.ts @@ -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(null); - const reconnectTimeoutRef = useRef | 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, }; } diff --git a/client/src/shared/lib/api.ts b/client/src/shared/lib/api.ts index fe19dff..c167383 100644 --- a/client/src/shared/lib/api.ts +++ b/client/src/shared/lib/api.ts @@ -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(); diff --git a/client/src/shared/types/index.ts b/client/src/shared/types/index.ts index 3e8ca03..c7c8fce 100644 --- a/client/src/shared/types/index.ts +++ b/client/src/shared/types/index.ts @@ -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 { diff --git a/server/.env.example b/server/.env.example index 5d6e40d..8b4d91a 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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="" diff --git a/server/package.json b/server/package.json index 0ec9131..067db28 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/prisma/data/scraped-effects.json b/server/prisma/data/scraped-effects.json new file mode 100644 index 0000000..4e6e567 --- /dev/null +++ b/server/prisma/data/scraped-effects.json @@ -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" + } +] \ No newline at end of file diff --git a/server/prisma/migrations/20260120080237_add_alchemy_and_rest_system/migration.sql b/server/prisma/migrations/20260120080237_add_alchemy_and_rest_system/migration.sql new file mode 100644 index 0000000..e53af4b --- /dev/null +++ b/server/prisma/migrations/20260120080237_add_alchemy_and_rest_system/migration.sql @@ -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; diff --git a/server/prisma/migrations/20260120084113_add_is_infused_to_prepared_items/migration.sql b/server/prisma/migrations/20260120084113_add_is_infused_to_prepared_items/migration.sql new file mode 100644 index 0000000..88da69a --- /dev/null +++ b/server/prisma/migrations/20260120084113_add_is_infused_to_prepared_items/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "CharacterPreparedItem" ADD COLUMN "isInfused" BOOLEAN NOT NULL DEFAULT true; diff --git a/server/prisma/migrations/20260120103755_add_equipment_effect_field/migration.sql b/server/prisma/migrations/20260120103755_add_equipment_effect_field/migration.sql new file mode 100644 index 0000000..d871d61 --- /dev/null +++ b/server/prisma/migrations/20260120103755_add_equipment_effect_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Equipment" ADD COLUMN "effect" TEXT; diff --git a/server/prisma/migrations/20260120104540_add_translation_german_effect/migration.sql b/server/prisma/migrations/20260120104540_add_translation_german_effect/migration.sql new file mode 100644 index 0000000..7293d7e --- /dev/null +++ b/server/prisma/migrations/20260120104540_add_translation_german_effect/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Translation" ADD COLUMN "germanEffect" TEXT; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index cc2e8d5..7c6472e 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -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()) diff --git a/server/prisma/seed-equipment.ts b/server/prisma/seed-equipment.ts index 0fa1a58..2f4e7a3 100644 --- a/server/prisma/seed-equipment.ts +++ b/server/prisma/seed-equipment.ts @@ -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++; diff --git a/server/src/modules/characters/alchemy.controller.ts b/server/src/modules/characters/alchemy.controller.ts new file mode 100644 index 0000000..967fd0f --- /dev/null +++ b/server/src/modules/characters/alchemy.controller.ts @@ -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); + } +} diff --git a/server/src/modules/characters/alchemy.service.ts b/server/src/modules/characters/alchemy.service.ts new file mode 100644 index 0000000..1edcd99 --- /dev/null +++ b/server/src/modules/characters/alchemy.service.ts @@ -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 { + // 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> & { 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; + } +} diff --git a/server/src/modules/characters/characters.controller.ts b/server/src/modules/characters/characters.controller.ts index 8b978b6..8c36ce9 100644 --- a/server/src/modules/characters/characters.controller.ts +++ b/server/src/modules/characters/characters.controller.ts @@ -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); + } } diff --git a/server/src/modules/characters/characters.gateway.ts b/server/src/modules/characters/characters.gateway.ts index 0664e2d..551eba2 100644 --- a/server/src/modules/characters/characters.gateway.ts +++ b/server/src/modules/characters/characters.gateway.ts @@ -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', diff --git a/server/src/modules/characters/characters.module.ts b/server/src/modules/characters/characters.module.ts index 32d5927..c8eb4a3 100644 --- a/server/src/modules/characters/characters.module.ts +++ b/server/src/modules/characters/characters.module.ts @@ -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 {} diff --git a/server/src/modules/characters/characters.service.ts b/server/src/modules/characters/characters.service.ts index 0460988..1662211 100644 --- a/server/src/modules/characters/characters.service.ts +++ b/server/src/modules/characters/characters.service.ts @@ -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 { + 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 { + 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, + }; + } } diff --git a/server/src/modules/characters/dto/alchemy.dto.ts b/server/src/modules/characters/dto/alchemy.dto.ts new file mode 100644 index 0000000..d55147e --- /dev/null +++ b/server/src/modules/characters/dto/alchemy.dto.ts @@ -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[]; +} diff --git a/server/src/modules/characters/dto/index.ts b/server/src/modules/characters/dto/index.ts index d742f5e..178d2ba 100644 --- a/server/src/modules/characters/dto/index.ts +++ b/server/src/modules/characters/dto/index.ts @@ -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'; diff --git a/server/src/modules/characters/dto/rest.dto.ts b/server/src/modules/characters/dto/rest.dto.ts new file mode 100644 index 0000000..83c7a14 --- /dev/null +++ b/server/src/modules/characters/dto/rest.dto.ts @@ -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; +} diff --git a/server/src/modules/characters/pathbuilder-import.service.ts b/server/src/modules/characters/pathbuilder-import.service.ts index e15ea31..f027bb6 100644 --- a/server/src/modules/characters/pathbuilder-import.service.ts +++ b/server/src/modules/characters/pathbuilder-import.service.ts @@ -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 = { + 'bomber': ResearchField.BOMBER, + 'chirurgeon': ResearchField.CHIRURGEON, + 'mutagenist': ResearchField.MUTAGENIST, + 'toxicologist': ResearchField.TOXICOLOGIST, +}; + // Skill name mappings (English -> German) const SKILL_TRANSLATIONS: Record = { 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(); + 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 { + // 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`); + } } diff --git a/server/src/modules/claude/claude.service.ts b/server/src/modules/claude/claude.service.ts index eec90fd..2274e35 100644 --- a/server/src/modules/claude/claude.service.ts +++ b/server/src/modules/claude/claude.service.ts @@ -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, })); } diff --git a/server/src/modules/translations/translations.service.ts b/server/src/modules/translations/translations.service.ts index dd84a1e..37bdee3 100644 --- a/server/src/modules/translations/translations.service.ts +++ b/server/src/modules/translations/translations.service.ts @@ -19,18 +19,20 @@ export class TranslationsService { type: TranslationType, englishName: string, englishDescription?: string, + englishEffect?: string, ): Promise { // 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> { const result = new Map(); @@ -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; } }