feat: Implement PF2e Alchemy and Rest system

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

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

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

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

6
client/.env.example Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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