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:
6
client/.env.example
Normal file
6
client/.env.example
Normal file
@@ -0,0 +1,6 @@
|
||||
# Dimension47 Client Environment Variables
|
||||
|
||||
# API Configuration
|
||||
# The base URL for the backend API (must match server PORT)
|
||||
# WebSocket URL is derived automatically by removing /api suffix
|
||||
VITE_API_URL=http://localhost:5000/api
|
||||
1748
client/src/features/characters/components/alchemy-tab.tsx
Normal file
1748
client/src/features/characters/components/alchemy-tab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ import {
|
||||
User,
|
||||
Star,
|
||||
Coins,
|
||||
Moon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
@@ -34,8 +35,10 @@ import { EditCharacterModal } from './edit-character-modal';
|
||||
import { AddFeatModal } from './add-feat-modal';
|
||||
import { FeatDetailModal } from './feat-detail-modal';
|
||||
import { ActionsTab } from './actions-tab';
|
||||
import { RestModal } from './rest-modal';
|
||||
import { AlchemyTab } from './alchemy-tab';
|
||||
import { useCharacterSocket } from '@/shared/hooks/use-character-socket';
|
||||
import type { Character, CharacterItem, CharacterFeat, Campaign } from '@/shared/types';
|
||||
import type { Character, CharacterItem, CharacterFeat, Campaign, RestResult, CharacterAlchemyState, CharacterFormula, CharacterPreparedItem } from '@/shared/types';
|
||||
|
||||
type TabType = 'status' | 'skills' | 'inventory' | 'feats' | 'spells' | 'alchemy' | 'actions';
|
||||
|
||||
@@ -129,6 +132,7 @@ export function CharacterSheetPage() {
|
||||
const [selectedFeat, setSelectedFeat] = useState<CharacterFeat | null>(null);
|
||||
const [editingCredits, setEditingCredits] = useState(false);
|
||||
const [creditsInput, setCreditsInput] = useState('');
|
||||
const [showRestModal, setShowRestModal] = useState(false);
|
||||
|
||||
const isOwner = character?.ownerId === user?.id;
|
||||
const isGM = campaign?.gmId === user?.id;
|
||||
@@ -211,6 +215,99 @@ export function CharacterSheetPage() {
|
||||
onLevelUpdate: (data) => {
|
||||
setCharacter((prev) => prev ? { ...prev, level: data.level } : null);
|
||||
},
|
||||
onRestUpdate: (data) => {
|
||||
setCharacter((prev) => {
|
||||
if (!prev) return null;
|
||||
// Update HP
|
||||
let updated = { ...prev, hpCurrent: data.hpCurrent };
|
||||
// Remove conditions
|
||||
if (data.conditionsRemoved.length > 0) {
|
||||
updated.conditions = updated.conditions.filter(
|
||||
(c) => !data.conditionsRemoved.includes(c.name) && !data.conditionsRemoved.includes(c.nameGerman || '')
|
||||
);
|
||||
}
|
||||
// Update reduced conditions
|
||||
if (data.conditionsReduced.length > 0) {
|
||||
updated.conditions = updated.conditions.map((c) => {
|
||||
const reduced = data.conditionsReduced.find(
|
||||
(r) => r.name === c.name || r.name === c.nameGerman
|
||||
);
|
||||
if (reduced) {
|
||||
return { ...c, value: reduced.newValue };
|
||||
}
|
||||
return c;
|
||||
});
|
||||
}
|
||||
// Reset resources
|
||||
if (data.resourcesReset.length > 0) {
|
||||
updated.resources = updated.resources.map((r) => {
|
||||
if (data.resourcesReset.includes(r.name)) {
|
||||
return { ...r, current: r.max };
|
||||
}
|
||||
return r;
|
||||
});
|
||||
}
|
||||
// Reset alchemy
|
||||
if (data.alchemyReset) {
|
||||
// Always clear prepared items when alchemy is reset
|
||||
updated.preparedItems = [];
|
||||
// Update alchemy state if it exists
|
||||
if (updated.alchemyState) {
|
||||
updated.alchemyState = {
|
||||
...updated.alchemyState,
|
||||
versatileVialsCurrent: updated.alchemyState.versatileVialsMax,
|
||||
advancedAlchemyBatch: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
onAlchemyVialsUpdate: (data) => {
|
||||
setCharacter((prev) => {
|
||||
if (!prev || !prev.alchemyState) return prev;
|
||||
return {
|
||||
...prev,
|
||||
alchemyState: { ...prev.alchemyState, versatileVialsCurrent: data.versatileVialsCurrent },
|
||||
};
|
||||
});
|
||||
},
|
||||
onAlchemyFormulasUpdate: (data) => {
|
||||
setCharacter((prev) => {
|
||||
if (!prev) return prev;
|
||||
if (data.action === 'add' && data.formula) {
|
||||
return { ...prev, formulas: [...(prev.formulas || []), data.formula] };
|
||||
}
|
||||
if (data.action === 'remove' && data.formulaId) {
|
||||
return { ...prev, formulas: (prev.formulas || []).filter((f) => f.id !== data.formulaId) };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
onAlchemyPreparedUpdate: (data) => {
|
||||
setCharacter((prev) => {
|
||||
if (!prev) return prev;
|
||||
if (data.action === 'prepare' && data.items) {
|
||||
return { ...prev, preparedItems: [...(prev.preparedItems || []), ...data.items] };
|
||||
}
|
||||
if (data.action === 'quick_alchemy' && data.item) {
|
||||
return { ...prev, preparedItems: [data.item, ...(prev.preparedItems || [])] };
|
||||
}
|
||||
if (data.action === 'update' && data.item) {
|
||||
return {
|
||||
...prev,
|
||||
preparedItems: (prev.preparedItems || []).map((i) => i.id === data.item!.id ? data.item! : i),
|
||||
};
|
||||
}
|
||||
if (data.action === 'remove' && data.itemId) {
|
||||
return { ...prev, preparedItems: (prev.preparedItems || []).filter((i) => i.id !== data.itemId) };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
onAlchemyStateUpdate: (data) => {
|
||||
setCharacter((prev) => prev ? { ...prev, alchemyState: data } : prev);
|
||||
},
|
||||
onFullUpdate: (updatedCharacter) => {
|
||||
setCharacter(updatedCharacter);
|
||||
},
|
||||
@@ -680,6 +777,16 @@ export function CharacterSheetPage() {
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Rest Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mt-4 border-indigo-500/30 text-indigo-400 hover:bg-indigo-500/10 hover:text-indigo-300"
|
||||
onClick={() => setShowRestModal(true)}
|
||||
>
|
||||
<Moon className="h-4 w-4 mr-2" />
|
||||
Rasten (8 Stunden)
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1323,69 +1430,24 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderAlchemyTab = () => (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FlaskConical className="h-5 w-5" />
|
||||
Alchemie-Ressourcen
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{character.resources.filter(r => r.name.toLowerCase().includes('vial') || r.name.toLowerCase().includes('alchemy')).length === 0 ? (
|
||||
<p className="text-center text-text-secondary py-4">
|
||||
Keine Alchemie-Ressourcen verfügbar
|
||||
</p>
|
||||
) : (
|
||||
character.resources.filter(r => r.name.toLowerCase().includes('vial') || r.name.toLowerCase().includes('alchemy')).map((resource) => (
|
||||
<div key={resource.id} className="flex items-center justify-between p-3 rounded-lg bg-bg-secondary">
|
||||
<span className="font-medium text-text-primary">{resource.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="icon" variant="ghost" className="h-8 w-8">
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="font-bold text-text-primary min-w-[60px] text-center">
|
||||
{resource.current} / {resource.max}
|
||||
</span>
|
||||
<Button size="icon" variant="ghost" className="h-8 w-8">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Alchemical Items from Inventory */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alchemistische Gegenstände</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{character.items.filter(i => i.name.toLowerCase().includes('bomb') || i.name.toLowerCase().includes('elixir') || i.name.toLowerCase().includes('mutagen')).length === 0 ? (
|
||||
<p className="text-center text-text-secondary py-4">
|
||||
Keine alchemistischen Gegenstände
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{character.items.filter(i => i.name.toLowerCase().includes('bomb') || i.name.toLowerCase().includes('elixir') || i.name.toLowerCase().includes('mutagen')).map((item) => (
|
||||
<div key={item.id} className="p-3 rounded-lg bg-bg-secondary">
|
||||
<span className="font-medium text-text-primary">
|
||||
{item.nameGerman || item.name}
|
||||
{item.quantity > 1 && ` (×${item.quantity})`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
const renderAlchemyTab = () => {
|
||||
if (!campaignId) return null;
|
||||
return (
|
||||
<AlchemyTab
|
||||
character={character}
|
||||
campaignId={campaignId}
|
||||
onStateUpdate={(state) => {
|
||||
setCharacter((prev) => prev ? { ...prev, alchemyState: state } : prev);
|
||||
}}
|
||||
onFormulasUpdate={(formulas) => {
|
||||
setCharacter((prev) => prev ? { ...prev, formulas } : prev);
|
||||
}}
|
||||
onPreparedItemsUpdate={(items) => {
|
||||
setCharacter((prev) => prev ? { ...prev, preparedItems: items } : prev);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderActionsTab = () => {
|
||||
return <ActionsTab characterFeats={character.feats} />;
|
||||
@@ -1527,6 +1589,17 @@ export function CharacterSheetPage() {
|
||||
onRemove={() => handleRemoveFeat(selectedFeat.id)}
|
||||
/>
|
||||
)}
|
||||
{showRestModal && campaignId && (
|
||||
<RestModal
|
||||
campaignId={campaignId}
|
||||
characterId={character.id}
|
||||
onClose={() => setShowRestModal(false)}
|
||||
onRestComplete={(result) => {
|
||||
// The WebSocket will handle the state update
|
||||
console.log('Rest complete:', result);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
199
client/src/features/characters/components/rest-modal.tsx
Normal file
199
client/src/features/characters/components/rest-modal.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Moon, Heart, Shield, Sparkles, FlaskConical, X, Loader2 } from 'lucide-react';
|
||||
import { Button, Card, CardContent } from '@/shared/components/ui';
|
||||
import { api } from '@/shared/lib/api';
|
||||
import type { RestPreview, RestResult } from '@/shared/types';
|
||||
|
||||
interface RestModalProps {
|
||||
campaignId: string;
|
||||
characterId: string;
|
||||
onClose: () => void;
|
||||
onRestComplete: (result: RestResult) => void;
|
||||
}
|
||||
|
||||
export function RestModal({ campaignId, characterId, onClose, onRestComplete }: RestModalProps) {
|
||||
const [preview, setPreview] = useState<RestPreview | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isResting, setIsResting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadPreview();
|
||||
}, [campaignId, characterId]);
|
||||
|
||||
const loadPreview = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await api.getRestPreview(campaignId, characterId);
|
||||
setPreview(data);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden der Vorschau');
|
||||
console.error('Failed to load rest preview:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRest = async () => {
|
||||
try {
|
||||
setIsResting(true);
|
||||
const result = await api.performRest(campaignId, characterId);
|
||||
onRestComplete(result);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Rasten');
|
||||
console.error('Failed to perform rest:', err);
|
||||
} finally {
|
||||
setIsResting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onClose}>
|
||||
<Card className="w-full max-w-md" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-indigo-500/20 flex items-center justify-center">
|
||||
<Moon className="h-5 w-5 text-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-text-primary">Rasten</h2>
|
||||
<p className="text-xs text-text-secondary">8 Stunden + Tägliche Vorbereitung</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="p-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary-500" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-4 text-red-400">{error}</div>
|
||||
) : preview ? (
|
||||
<div className="space-y-4">
|
||||
{/* HP Healing */}
|
||||
{preview.hpToHeal > 0 && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<Heart className="h-5 w-5 text-green-400 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-400">HP-Heilung</p>
|
||||
<p className="text-xs text-text-secondary">
|
||||
+{preview.hpToHeal} HP (auf {preview.hpAfterRest})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conditions to Remove */}
|
||||
{preview.conditionsToRemove.length > 0 && (
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<Shield className="h-5 w-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-400">Zustände entfernen</p>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{preview.conditionsToRemove.map((c, i) => (
|
||||
<span key={i} className="text-xs px-2 py-0.5 rounded bg-red-500/20 text-red-300">
|
||||
{c}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conditions to Reduce */}
|
||||
{preview.conditionsToReduce.length > 0 && (
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||
<Shield className="h-5 w-5 text-yellow-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-yellow-400">Zustände reduzieren</p>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{preview.conditionsToReduce.map((c, i) => (
|
||||
<span key={i} className="text-xs px-2 py-0.5 rounded bg-yellow-500/20 text-yellow-300">
|
||||
{c.name} {c.oldValue} → {c.newValue}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resources to Reset */}
|
||||
{preview.resourcesToReset.length > 0 && (
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||
<Sparkles className="h-5 w-5 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-400">Ressourcen auffüllen</p>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{preview.resourcesToReset.map((r, i) => (
|
||||
<span key={i} className="text-xs px-2 py-0.5 rounded bg-blue-500/20 text-blue-300">
|
||||
{r}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alchemy Reset */}
|
||||
{preview.alchemyReset && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-purple-500/10 border border-purple-500/20">
|
||||
<FlaskConical className="h-5 w-5 text-purple-400 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-purple-400">Alchemie zurücksetzen</p>
|
||||
<p className="text-xs text-text-secondary">
|
||||
{preview.infusedItemsCount > 0
|
||||
? `${preview.infusedItemsCount} infundierte Items verfallen, Phiolen werden aufgefüllt`
|
||||
: 'Phiolen werden aufgefüllt'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nothing to do */}
|
||||
{preview.hpToHeal === 0 &&
|
||||
preview.conditionsToRemove.length === 0 &&
|
||||
preview.conditionsToReduce.length === 0 &&
|
||||
preview.resourcesToReset.length === 0 &&
|
||||
!preview.alchemyReset && (
|
||||
<div className="text-center py-4 text-text-secondary">
|
||||
<p>Dein Charakter ist bereits vollständig erholt.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 mt-6">
|
||||
<Button variant="outline" className="flex-1" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 bg-indigo-600 hover:bg-indigo-700"
|
||||
onClick={handleRest}
|
||||
disabled={isLoading || isResting}
|
||||
>
|
||||
{isResting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Raste...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Moon className="h-4 w-4 mr-2" />
|
||||
Rasten
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { api } from '@/shared/lib/api';
|
||||
import type { Character, CharacterItem, CharacterCondition } from '@/shared/types';
|
||||
import type { Character, CharacterItem, CharacterCondition, CharacterAlchemyState, CharacterFormula, CharacterPreparedItem, ConditionReduced } from '@/shared/types';
|
||||
|
||||
const SOCKET_URL = import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:3001';
|
||||
// Derive WebSocket URL from API URL (remove /api suffix)
|
||||
const SOCKET_URL = import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:5000';
|
||||
|
||||
export type CharacterUpdateType = 'hp' | 'conditions' | 'item' | 'inventory' | 'money' | 'level' | 'equipment_status';
|
||||
// Singleton socket manager to prevent multiple connections
|
||||
let globalSocket: Socket | null = null;
|
||||
let globalSocketRefCount = 0;
|
||||
let currentCharacterId: string | null = null;
|
||||
let connectionAttempted = false;
|
||||
|
||||
export type CharacterUpdateType = 'hp' | 'conditions' | 'item' | 'inventory' | 'money' | 'level' | 'equipment_status' | 'rest' | 'alchemy_vials' | 'alchemy_formulas' | 'alchemy_prepared' | 'alchemy_state';
|
||||
|
||||
export interface CharacterUpdate {
|
||||
characterId: string;
|
||||
@@ -13,6 +20,15 @@ export interface CharacterUpdate {
|
||||
data: any;
|
||||
}
|
||||
|
||||
export interface RestUpdateData {
|
||||
hpCurrent: number;
|
||||
hpHealed: number;
|
||||
conditionsRemoved: string[];
|
||||
conditionsReduced: ConditionReduced[];
|
||||
resourcesReset: string[];
|
||||
alchemyReset: boolean;
|
||||
}
|
||||
|
||||
interface UseCharacterSocketOptions {
|
||||
characterId: string;
|
||||
onHpUpdate?: (data: { hpCurrent: number; hpTemp: number; hpMax: number }) => void;
|
||||
@@ -21,6 +37,11 @@ interface UseCharacterSocketOptions {
|
||||
onEquipmentStatusUpdate?: (data: { action: 'update'; item: CharacterItem }) => void;
|
||||
onMoneyUpdate?: (data: { credits: number }) => void;
|
||||
onLevelUpdate?: (data: { level: number }) => void;
|
||||
onRestUpdate?: (data: RestUpdateData) => void;
|
||||
onAlchemyVialsUpdate?: (data: { versatileVialsCurrent: number }) => void;
|
||||
onAlchemyFormulasUpdate?: (data: { action: 'add' | 'remove'; formula?: CharacterFormula; formulaId?: string }) => void;
|
||||
onAlchemyPreparedUpdate?: (data: { action: 'add' | 'update' | 'remove' | 'prepare' | 'quick_alchemy'; item?: CharacterPreparedItem; items?: CharacterPreparedItem[]; itemId?: string; batchUsed?: number }) => void;
|
||||
onAlchemyStateUpdate?: (data: CharacterAlchemyState) => void;
|
||||
onFullUpdate?: (character: Character) => void;
|
||||
}
|
||||
|
||||
@@ -32,114 +53,202 @@ export function useCharacterSocket({
|
||||
onEquipmentStatusUpdate,
|
||||
onMoneyUpdate,
|
||||
onLevelUpdate,
|
||||
onRestUpdate,
|
||||
onAlchemyVialsUpdate,
|
||||
onAlchemyFormulasUpdate,
|
||||
onAlchemyPreparedUpdate,
|
||||
onAlchemyStateUpdate,
|
||||
onFullUpdate,
|
||||
}: UseCharacterSocketOptions) {
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
// Use refs for callbacks to avoid reconnection on callback changes
|
||||
const callbacksRef = useRef({
|
||||
onHpUpdate,
|
||||
onConditionsUpdate,
|
||||
onInventoryUpdate,
|
||||
onEquipmentStatusUpdate,
|
||||
onMoneyUpdate,
|
||||
onLevelUpdate,
|
||||
onRestUpdate,
|
||||
onAlchemyVialsUpdate,
|
||||
onAlchemyFormulasUpdate,
|
||||
onAlchemyPreparedUpdate,
|
||||
onAlchemyStateUpdate,
|
||||
onFullUpdate,
|
||||
});
|
||||
|
||||
// Update refs when callbacks change (without causing reconnection)
|
||||
useEffect(() => {
|
||||
callbacksRef.current = {
|
||||
onHpUpdate,
|
||||
onConditionsUpdate,
|
||||
onInventoryUpdate,
|
||||
onEquipmentStatusUpdate,
|
||||
onMoneyUpdate,
|
||||
onLevelUpdate,
|
||||
onRestUpdate,
|
||||
onAlchemyVialsUpdate,
|
||||
onAlchemyFormulasUpdate,
|
||||
onAlchemyPreparedUpdate,
|
||||
onAlchemyStateUpdate,
|
||||
onFullUpdate,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
const token = api.getToken();
|
||||
if (!token || !characterId) return;
|
||||
|
||||
// Disconnect existing socket if any
|
||||
if (socketRef.current?.connected) {
|
||||
socketRef.current.disconnect();
|
||||
// Increment ref count
|
||||
globalSocketRefCount++;
|
||||
|
||||
// If we already have a socket for a different character, leave that room first
|
||||
if (globalSocket?.connected && currentCharacterId && currentCharacterId !== characterId) {
|
||||
globalSocket.emit('leave_character', { characterId: currentCharacterId });
|
||||
}
|
||||
|
||||
const socket = io(`${SOCKET_URL}/characters`, {
|
||||
auth: { token },
|
||||
transports: ['websocket', 'polling'],
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 1000,
|
||||
});
|
||||
// Update current character
|
||||
currentCharacterId = characterId;
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('[WebSocket] Connected to character namespace');
|
||||
// Join the character room
|
||||
socket.emit('join_character', { characterId }, (response: { success: boolean; error?: string }) => {
|
||||
if (response.success) {
|
||||
console.log(`[WebSocket] Joined character room: ${characterId}`);
|
||||
} else {
|
||||
console.error(`[WebSocket] Failed to join character room: ${response.error}`);
|
||||
// Create socket if it doesn't exist and we haven't already tried
|
||||
if (!globalSocket && !connectionAttempted) {
|
||||
connectionAttempted = true;
|
||||
|
||||
globalSocket = io(`${SOCKET_URL}/characters`, {
|
||||
auth: { token },
|
||||
// Start with polling to avoid browser WebSocket errors when server is down
|
||||
// Socket.io will automatically upgrade to websocket when connected
|
||||
transports: ['polling', 'websocket'],
|
||||
upgrade: true,
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 3,
|
||||
reconnectionDelay: 3000,
|
||||
reconnectionDelayMax: 10000,
|
||||
timeout: 10000,
|
||||
autoConnect: true,
|
||||
});
|
||||
|
||||
globalSocket.on('connect', () => {
|
||||
if (!mountedRef.current) return;
|
||||
console.log('[WebSocket] Connected');
|
||||
setIsConnected(true);
|
||||
|
||||
// Join character room on connect/reconnect
|
||||
if (currentCharacterId && globalSocket) {
|
||||
globalSocket.emit('join_character', { characterId: currentCharacterId }, (response: { success: boolean; error?: string }) => {
|
||||
if (response.success) {
|
||||
console.log(`[WebSocket] Joined room: ${currentCharacterId}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log(`[WebSocket] Disconnected: ${reason}`);
|
||||
});
|
||||
globalSocket.on('disconnect', (reason) => {
|
||||
console.log(`[WebSocket] Disconnected: ${reason}`);
|
||||
if (mountedRef.current) {
|
||||
setIsConnected(false);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('[WebSocket] Connection error:', error.message);
|
||||
});
|
||||
globalSocket.on('connect_error', () => {
|
||||
// Silently handle - socket.io will retry automatically
|
||||
if (mountedRef.current) {
|
||||
setIsConnected(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle character updates
|
||||
socket.on('character_update', (update: CharacterUpdate) => {
|
||||
console.log(`[WebSocket] Received update: ${update.type}`, update.data);
|
||||
// Handle character updates - use refs to always call latest callbacks
|
||||
globalSocket.on('character_update', (update: CharacterUpdate) => {
|
||||
if (!mountedRef.current) return;
|
||||
const callbacks = callbacksRef.current;
|
||||
|
||||
switch (update.type) {
|
||||
case 'hp':
|
||||
onHpUpdate?.(update.data);
|
||||
break;
|
||||
case 'conditions':
|
||||
onConditionsUpdate?.(update.data);
|
||||
break;
|
||||
case 'inventory':
|
||||
onInventoryUpdate?.(update.data);
|
||||
break;
|
||||
case 'equipment_status':
|
||||
onEquipmentStatusUpdate?.(update.data);
|
||||
break;
|
||||
case 'money':
|
||||
onMoneyUpdate?.(update.data);
|
||||
break;
|
||||
case 'level':
|
||||
onLevelUpdate?.(update.data);
|
||||
break;
|
||||
case 'item':
|
||||
// Item update that's not equipment status (e.g., quantity, notes)
|
||||
onInventoryUpdate?.(update.data);
|
||||
break;
|
||||
}
|
||||
});
|
||||
switch (update.type) {
|
||||
case 'hp':
|
||||
callbacks.onHpUpdate?.(update.data);
|
||||
break;
|
||||
case 'conditions':
|
||||
callbacks.onConditionsUpdate?.(update.data);
|
||||
break;
|
||||
case 'inventory':
|
||||
callbacks.onInventoryUpdate?.(update.data);
|
||||
break;
|
||||
case 'equipment_status':
|
||||
callbacks.onEquipmentStatusUpdate?.(update.data);
|
||||
break;
|
||||
case 'money':
|
||||
callbacks.onMoneyUpdate?.(update.data);
|
||||
break;
|
||||
case 'level':
|
||||
callbacks.onLevelUpdate?.(update.data);
|
||||
break;
|
||||
case 'item':
|
||||
callbacks.onInventoryUpdate?.(update.data);
|
||||
break;
|
||||
case 'rest':
|
||||
callbacks.onRestUpdate?.(update.data);
|
||||
break;
|
||||
case 'alchemy_vials':
|
||||
callbacks.onAlchemyVialsUpdate?.(update.data);
|
||||
break;
|
||||
case 'alchemy_formulas':
|
||||
callbacks.onAlchemyFormulasUpdate?.(update.data);
|
||||
break;
|
||||
case 'alchemy_prepared':
|
||||
callbacks.onAlchemyPreparedUpdate?.(update.data);
|
||||
break;
|
||||
case 'alchemy_state':
|
||||
callbacks.onAlchemyStateUpdate?.(update.data);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle full character refresh (e.g., after reconnect)
|
||||
socket.on('character_refresh', (character: Character) => {
|
||||
console.log('[WebSocket] Received full character refresh');
|
||||
onFullUpdate?.(character);
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
return socket;
|
||||
}, [characterId, onHpUpdate, onConditionsUpdate, onInventoryUpdate, onEquipmentStatusUpdate, onMoneyUpdate, onLevelUpdate, onFullUpdate]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
globalSocket.on('character_refresh', (character: Character) => {
|
||||
if (!mountedRef.current) return;
|
||||
callbacksRef.current.onFullUpdate?.(character);
|
||||
});
|
||||
} else if (globalSocket?.connected) {
|
||||
// Socket already exists and connected, just join the room
|
||||
globalSocket.emit('join_character', { characterId }, (response: { success: boolean; error?: string }) => {
|
||||
if (response.success) {
|
||||
console.log(`[WebSocket] Joined room: ${characterId}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (socketRef.current) {
|
||||
// Leave the character room before disconnecting
|
||||
socketRef.current.emit('leave_character', { characterId });
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
}
|
||||
}, [characterId]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
disconnect();
|
||||
mountedRef.current = false;
|
||||
globalSocketRefCount--;
|
||||
|
||||
if (globalSocket && currentCharacterId === characterId) {
|
||||
globalSocket.emit('leave_character', { characterId });
|
||||
}
|
||||
|
||||
// Only disconnect socket if no more refs
|
||||
if (globalSocketRefCount <= 0 && globalSocket) {
|
||||
globalSocket.disconnect();
|
||||
globalSocket = null;
|
||||
currentCharacterId = null;
|
||||
connectionAttempted = false;
|
||||
}
|
||||
};
|
||||
}, [connect, disconnect]);
|
||||
}, [characterId]); // Only depend on characterId
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
if (globalSocket) {
|
||||
globalSocket.connect();
|
||||
} else {
|
||||
// Reset connection attempt flag to allow new connection
|
||||
connectionAttempted = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
socket: socketRef.current,
|
||||
isConnected: socketRef.current?.connected ?? false,
|
||||
reconnect: connect,
|
||||
socket: globalSocket,
|
||||
isConnected,
|
||||
reconnect,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -373,6 +373,107 @@ class ApiClient {
|
||||
const response = await this.client.get(`/feats/by-name/${encodeURIComponent(name)}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// REST SYSTEM
|
||||
// ==========================================
|
||||
|
||||
async getRestPreview(campaignId: string, characterId: string) {
|
||||
const response = await this.client.get(`/campaigns/${campaignId}/characters/${characterId}/rest/preview`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async performRest(campaignId: string, characterId: string) {
|
||||
const response = await this.client.post(`/campaigns/${campaignId}/characters/${characterId}/rest`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ALCHEMY SYSTEM
|
||||
// ==========================================
|
||||
|
||||
async getAlchemy(campaignId: string, characterId: string) {
|
||||
const response = await this.client.get(`/campaigns/${campaignId}/characters/${characterId}/alchemy`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async initializeAlchemy(campaignId: string, characterId: string, data: {
|
||||
researchField?: 'BOMBER' | 'CHIRURGEON' | 'MUTAGENIST' | 'TOXICOLOGIST';
|
||||
versatileVialsMax: number;
|
||||
advancedAlchemyMax: number;
|
||||
}) {
|
||||
const response = await this.client.post(`/campaigns/${campaignId}/characters/${characterId}/alchemy/initialize`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateVials(campaignId: string, characterId: string, current: number) {
|
||||
const response = await this.client.patch(`/campaigns/${campaignId}/characters/${characterId}/alchemy/vials`, { current });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async refillVials(campaignId: string, characterId: string) {
|
||||
const response = await this.client.post(`/campaigns/${campaignId}/characters/${characterId}/alchemy/vials/refill`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getFormulas(campaignId: string, characterId: string) {
|
||||
const response = await this.client.get(`/campaigns/${campaignId}/characters/${characterId}/alchemy/formulas`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getAvailableFormulas(campaignId: string, characterId: string) {
|
||||
const response = await this.client.get(`/campaigns/${campaignId}/characters/${characterId}/alchemy/formulas/available`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async addFormula(campaignId: string, characterId: string, data: {
|
||||
equipmentId: string;
|
||||
learnedAt?: number;
|
||||
formulaSource?: string;
|
||||
}) {
|
||||
const response = await this.client.post(`/campaigns/${campaignId}/characters/${characterId}/alchemy/formulas`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async removeFormula(campaignId: string, characterId: string, formulaId: string) {
|
||||
const response = await this.client.delete(`/campaigns/${campaignId}/characters/${characterId}/alchemy/formulas/${formulaId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async refreshFormulaTranslations(campaignId: string, characterId: string) {
|
||||
const response = await this.client.post(`/campaigns/${campaignId}/characters/${characterId}/alchemy/formulas/refresh-translations`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getPreparedItems(campaignId: string, characterId: string) {
|
||||
const response = await this.client.get(`/campaigns/${campaignId}/characters/${characterId}/alchemy/prepared`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async dailyPreparation(campaignId: string, characterId: string, items: Array<{ equipmentId: string; quantity: number }>) {
|
||||
const response = await this.client.post(`/campaigns/${campaignId}/characters/${characterId}/alchemy/prepare`, { items });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async quickAlchemy(campaignId: string, characterId: string, equipmentId: string) {
|
||||
const response = await this.client.post(`/campaigns/${campaignId}/characters/${characterId}/alchemy/quick`, { equipmentId });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async craftAlchemy(campaignId: string, characterId: string, equipmentId: string, quantity: number) {
|
||||
const response = await this.client.post(`/campaigns/${campaignId}/characters/${characterId}/alchemy/craft`, { equipmentId, quantity });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async consumePreparedItem(campaignId: string, characterId: string, itemId: string) {
|
||||
const response = await this.client.patch(`/campaigns/${campaignId}/characters/${characterId}/alchemy/prepared/${itemId}/consume`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deletePreparedItem(campaignId: string, characterId: string, itemId: string) {
|
||||
const response = await this.client.delete(`/campaigns/${campaignId}/characters/${characterId}/alchemy/prepared/${itemId}`);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
|
||||
@@ -78,6 +78,10 @@ export interface Character extends CharacterSummary {
|
||||
items: CharacterItem[];
|
||||
conditions: CharacterCondition[];
|
||||
resources: CharacterResource[];
|
||||
// Alchemy
|
||||
alchemyState?: CharacterAlchemyState;
|
||||
formulas?: CharacterFormula[];
|
||||
preparedItems?: CharacterPreparedItem[];
|
||||
}
|
||||
|
||||
export interface CharacterAbility {
|
||||
@@ -158,6 +162,78 @@ export interface CharacterResource {
|
||||
max: number;
|
||||
}
|
||||
|
||||
// Alchemy Types
|
||||
export type ResearchField = 'BOMBER' | 'CHIRURGEON' | 'MUTAGENIST' | 'TOXICOLOGIST';
|
||||
|
||||
export interface CharacterAlchemyState {
|
||||
id: string;
|
||||
characterId: string;
|
||||
researchField?: ResearchField;
|
||||
versatileVialsCurrent: number;
|
||||
versatileVialsMax: number;
|
||||
advancedAlchemyBatch: number;
|
||||
advancedAlchemyMax: number;
|
||||
lastRestAt?: string;
|
||||
}
|
||||
|
||||
export interface CharacterFormula {
|
||||
id: string;
|
||||
characterId: string;
|
||||
equipmentId: string;
|
||||
name: string;
|
||||
nameGerman?: string;
|
||||
learnedAt: number;
|
||||
formulaSource?: string;
|
||||
equipment?: Equipment;
|
||||
}
|
||||
|
||||
export interface CharacterPreparedItem {
|
||||
id: string;
|
||||
characterId: string;
|
||||
equipmentId: string;
|
||||
name: string;
|
||||
nameGerman?: string;
|
||||
quantity: number;
|
||||
isQuickAlchemy: boolean;
|
||||
isInfused: boolean;
|
||||
createdAt: string;
|
||||
equipment?: Equipment;
|
||||
}
|
||||
|
||||
// Available formula - equipment with isLearned flag for auto-upgrade system
|
||||
export interface AvailableFormula extends Equipment {
|
||||
nameGerman?: string;
|
||||
summaryGerman?: string;
|
||||
effectGerman?: string;
|
||||
isLearned: boolean; // true if this exact version was learned, false if it's an upgraded version
|
||||
}
|
||||
|
||||
// Rest Types
|
||||
export interface ConditionReduced {
|
||||
name: string;
|
||||
oldValue: number;
|
||||
newValue: number;
|
||||
}
|
||||
|
||||
export interface RestPreview {
|
||||
hpToHeal: number;
|
||||
hpAfterRest: number;
|
||||
conditionsToRemove: string[];
|
||||
conditionsToReduce: ConditionReduced[];
|
||||
resourcesToReset: string[];
|
||||
alchemyReset: boolean;
|
||||
infusedItemsCount: number;
|
||||
}
|
||||
|
||||
export interface RestResult {
|
||||
hpHealed: number;
|
||||
hpCurrent: number;
|
||||
conditionsRemoved: string[];
|
||||
conditionsReduced: ConditionReduced[];
|
||||
resourcesReset: string[];
|
||||
alchemyReset: boolean;
|
||||
}
|
||||
|
||||
// Battle Types
|
||||
export interface BattleMap {
|
||||
id: string;
|
||||
@@ -281,6 +357,8 @@ export interface Equipment {
|
||||
activation?: string;
|
||||
duration?: string;
|
||||
usage?: string;
|
||||
effect?: string; // Specific effect text for item variants (Lesser, Moderate, Greater, Major)
|
||||
effectGerman?: string; // Translated effect text
|
||||
}
|
||||
|
||||
export interface EquipmentSearchResult {
|
||||
|
||||
Reference in New Issue
Block a user