diff --git a/client/src/features/characters/components/character-sheet-page.tsx b/client/src/features/characters/components/character-sheet-page.tsx
index ce58e80..45b7833 100644
--- a/client/src/features/characters/components/character-sheet-page.tsx
+++ b/client/src/features/characters/components/character-sheet-page.tsx
@@ -16,6 +16,9 @@ import {
Star,
Coins,
Moon,
+ Download,
+ Eye,
+ Footprints,
} from 'lucide-react';
import {
Button,
@@ -38,6 +41,7 @@ import { ActionsTab } from './actions-tab';
import { RestModal } from './rest-modal';
import { AlchemyTab } from './alchemy-tab';
import { useCharacterSocket } from '@/shared/hooks/use-character-socket';
+import { downloadCharacterHTML } from '../utils/export-character-html';
import type { Character, CharacterItem, CharacterFeat, Campaign, RestResult, CharacterAlchemyState, CharacterFormula, CharacterPreparedItem } from '@/shared/types';
type TabType = 'status' | 'skills' | 'inventory' | 'feats' | 'spells' | 'alchemy' | 'actions';
@@ -600,49 +604,54 @@ export function CharacterSheetPage() {
const acData = calculateAC();
- return (
-
- {/* HP & AC Row - 75/25 split */}
-
- {/* HP Control - 75% */}
-
-
-
+ // Get speed from pathbuilder data
+ const pbSpeed = character.pathbuilderData as {
+ build?: { attributes?: { speed?: number } };
+ } | undefined;
+ const speed = pbSpeed?.build?.attributes?.speed || 25;
- {/* AC Display - Shield Shape */}
-
-
- {/* Shield SVG */}
-
- {/* AC Number centered on shield */}
-
-
RK
-
{acData.total}
+ return (
+
+ {/* HP Row - Full width on mobile */}
+
+
+ {/* AC & Speed Row */}
+
+ {/* AC Card */}
+
+
+
+
+ Rüstungsklasse
+
+
+
{acData.total}
{acData.shieldBonus > 0 && (
-
+{acData.shieldBonus}
+
+{acData.shieldBonus}
)}
-
- {/* Proficiency Info */}
-
-
- {acData.profName}
-
-
+
{acData.profRank}
-
-
+
+
+
+ {/* Speed Card */}
+
+
+
+
+ Geschwindigkeit
+
+ {speed}
+ ft / Aktion
+
+
{/* Abilities */}
@@ -670,77 +679,134 @@ export function CharacterSheetPage() {
})}
- {/* Saving Throws */}
+ {/* Saving Throws & Perception */}
{(() => {
const getAbilityMod = (abilityType: 'STR' | 'DEX' | 'CON' | 'INT' | 'WIS' | 'CHA') => {
const ability = character.abilities.find(a => a.ability === abilityType);
return ability ? Math.floor((ability.score - 10) / 2) : 0;
};
- // TODO: Get actual proficiency from Pathbuilder data when available
- // For now, assume trained (+2) as baseline for all saves
- const baseProficiency = 2;
const level = character.level;
+ // Get proficiency values from Pathbuilder data
+ const pb = character.pathbuilderData as {
+ build?: {
+ proficiencies?: Record
;
+ };
+ } | undefined;
+ const proficiencies = pb?.build?.proficiencies || {};
+
+ // Calculate save totals using actual proficiency data
+ // Proficiency values: 0 = untrained, 2 = trained, 4 = expert, 6 = master, 8 = legendary
+ const getSaveTotal = (saveName: string, abilityMod: number) => {
+ const prof = proficiencies[saveName] || 2; // Default to trained if not found
+ const levelBonus = prof > 0 ? level : 0;
+ return abilityMod + levelBonus + prof;
+ };
+
+ const getSaveProfName = (saveName: string) => {
+ const prof = proficiencies[saveName] || 2;
+ if (prof === 0) return 'Ungeübt';
+ if (prof === 2) return 'Geübt';
+ if (prof === 4) return 'Experte';
+ if (prof === 6) return 'Meister';
+ if (prof === 8) return 'Legendär';
+ return 'Geübt';
+ };
+
const saves = [
{
name: 'Zähigkeit',
- shortName: 'ZÄH',
+ key: 'fortitude',
ability: 'CON' as const,
color: 'text-red-400',
bgColor: 'bg-red-500/20',
},
{
name: 'Reflex',
- shortName: 'REF',
+ key: 'reflex',
ability: 'DEX' as const,
color: 'text-green-400',
bgColor: 'bg-green-500/20',
},
{
name: 'Willen',
- shortName: 'WIL',
+ key: 'will',
ability: 'WIS' as const,
color: 'text-blue-400',
bgColor: 'bg-blue-500/20',
},
];
- return (
-
-
-
-
- Rettungswürfe
-
-
-
-
- {saves.map((save) => {
- const abilityMod = getAbilityMod(save.ability);
- const total = abilityMod + level + baseProficiency;
- const bonusString = total >= 0 ? `+${total}` : `${total}`;
+ // Calculate Perception
+ // Perception = WIS mod + proficiency bonus + level (if trained+)
+ const perceptionSkill = character.skills.find(s => s.skillName === 'Perception');
+ const perceptionProf = perceptionSkill
+ ? PROFICIENCY_BONUS[perceptionSkill.proficiency] || 0
+ : (proficiencies['perception'] || 2);
+ const perceptionProfName = perceptionSkill
+ ? PROFICIENCY_NAMES[perceptionSkill.proficiency] || 'Geübt'
+ : getSaveProfName('perception');
+ const wisdomMod = getAbilityMod('WIS');
+ const perceptionLevelBonus = perceptionProf > 0 ? level : 0;
+ const perceptionTotal = wisdomMod + perceptionLevelBonus + perceptionProf;
+ const perceptionString = perceptionTotal >= 0 ? `+${perceptionTotal}` : `${perceptionTotal}`;
- return (
-
-
- {save.name}
-
-
- {bonusString}
-
-
- {ABILITY_NAMES[save.ability].slice(0, 3).toUpperCase()}
-
-
- );
- })}
-
-
-
+ return (
+
+ {/* Perception */}
+
+
+
+
+
+ Wahrnehmung
+
+ {perceptionString}
+ {perceptionProfName}
+
+
+
+
+ {/* Saving Throws */}
+
+
+
+
+
+ Rettungswürfe
+
+
+
+
+ {saves.map((save) => {
+ const abilityMod = getAbilityMod(save.ability);
+ const total = getSaveTotal(save.key, abilityMod);
+ const bonusString = total >= 0 ? `+${total}` : `${total}`;
+ const profName = getSaveProfName(save.key);
+
+ return (
+
+
+ {save.name}
+
+
+ {bonusString}
+
+
+ {profName}
+
+
+ );
+ })}
+
+
+
+
+
);
})()}
@@ -779,14 +845,17 @@ export function CharacterSheetPage() {
{/* Rest Button */}
-
+
setShowRestModal(true)}>
+
+
+
+
+
+
Rasten
+
8 Stunden Ruhe + Tägliche Vorbereitung
+
+
+
);
};
@@ -1502,16 +1571,26 @@ export function CharacterSheetPage() {
- {isOwner && (
-
-
-
-
- )}
+
+
+ {isOwner && (
+ <>
+
+
+ >
+ )}
+
{/* Tab Navigation */}
diff --git a/client/src/features/characters/utils/export-character-html.ts b/client/src/features/characters/utils/export-character-html.ts
new file mode 100644
index 0000000..16c54bb
--- /dev/null
+++ b/client/src/features/characters/utils/export-character-html.ts
@@ -0,0 +1,867 @@
+import type { Character, Proficiency } from '@/shared/types';
+
+// Pathbuilder data structure
+interface PathbuilderData {
+ build?: {
+ name?: string;
+ class?: string;
+ ancestry?: string;
+ heritage?: string;
+ background?: string;
+ alignment?: string;
+ gender?: string;
+ age?: string;
+ deity?: string;
+ sizeName?: string;
+ languages?: string[];
+ attributes?: {
+ speed?: number;
+ classDC?: number;
+ };
+ acTotal?: {
+ acTotal?: number;
+ };
+ proficiencies?: Record;
+ perception?: number;
+ };
+ translations?: {
+ class?: { germanName?: string };
+ ancestry?: { germanName?: string };
+ heritage?: { germanName?: string };
+ background?: { germanName?: string };
+ };
+}
+
+// Skill data with German names and linked abilities
+const SKILL_DATA: Record = {
+ 'Acrobatics': { german: 'Akrobatik', ability: 'DEX' },
+ 'Arcana': { german: 'Arkane Künste', ability: 'INT' },
+ 'Athletics': { german: 'Athletik', ability: 'STR' },
+ 'Crafting': { german: 'Handwerkskunst', ability: 'INT' },
+ 'Deception': { german: 'Täuschung', ability: 'CHA' },
+ 'Diplomacy': { german: 'Diplomatie', ability: 'CHA' },
+ 'Intimidation': { german: 'Einschüchtern', ability: 'CHA' },
+ 'Medicine': { german: 'Medizin', ability: 'WIS' },
+ 'Nature': { german: 'Naturkunde', ability: 'WIS' },
+ 'Occultism': { german: 'Okkultismus', ability: 'INT' },
+ 'Performance': { german: 'Darbietung', ability: 'CHA' },
+ 'Religion': { german: 'Religionskunde', ability: 'WIS' },
+ 'Society': { german: 'Gesellschaftskunde', ability: 'INT' },
+ 'Stealth': { german: 'Heimlichkeit', ability: 'DEX' },
+ 'Survival': { german: 'Überleben', ability: 'WIS' },
+ 'Thievery': { german: 'Diebeskunst', ability: 'DEX' },
+};
+
+// Proficiency bonus values
+const PROFICIENCY_BONUS: Record = {
+ 'UNTRAINED': 0,
+ 'TRAINED': 2,
+ 'EXPERT': 4,
+ 'MASTER': 6,
+ 'LEGENDARY': 8,
+};
+
+// Helper to format modifier with +/- sign
+function formatMod(value: number): string {
+ return value >= 0 ? `+${value}` : `${value}`;
+}
+
+// Helper to get ability modifier
+function getAbilityMod(score: number): number {
+ return Math.floor((score - 10) / 2);
+}
+
+// Helper to format bulk
+function formatBulk(bulk: string | undefined): string {
+ if (!bulk) return '-';
+ if (bulk === 'L') return 'L';
+ return bulk;
+}
+
+// Get proficiency label
+function getProficiencyLabel(prof: Proficiency): string {
+ const labels: Record = {
+ 'UNTRAINED': 'Ungeübt',
+ 'TRAINED': 'Geübt',
+ 'EXPERT': 'Experte',
+ 'MASTER': 'Meister',
+ 'LEGENDARY': 'Legendär',
+ };
+ return labels[prof] || 'Ungeübt';
+}
+
+// Group feats by source
+function groupFeatsBySource(feats: Character['feats']) {
+ const groups: Record = {};
+ for (const feat of feats) {
+ const source = feat.source || 'BONUS';
+ if (!groups[source]) groups[source] = [];
+ groups[source].push(feat);
+ }
+ return groups;
+}
+
+// Translate feat source
+function translateFeatSource(source: string): string {
+ const translations: Record = {
+ 'CLASS': 'Klasse',
+ 'ANCESTRY': 'Abstammung',
+ 'GENERAL': 'Allgemein',
+ 'SKILL': 'Fertigkeit',
+ 'BONUS': 'Bonus',
+ 'ARCHETYPE': 'Archetyp',
+ };
+ return translations[source] || source;
+}
+
+export function generateCharacterHTML(character: Character): string {
+ const abilities = character.abilities || [];
+ const getAbility = (name: string) => abilities.find(a => a.ability === name)?.score || 10;
+
+ const STR = getAbility('STR');
+ const DEX = getAbility('DEX');
+ const CON = getAbility('CON');
+ const INT = getAbility('INT');
+ const WIS = getAbility('WIS');
+ const CHA = getAbility('CHA');
+
+ // Get pathbuilder data and translations
+ const pb = character.pathbuilderData as PathbuilderData | undefined;
+ const translations = pb?.translations;
+ const build = pb?.build;
+
+ // Get translated or original names
+ const ancestry = translations?.ancestry?.germanName || build?.ancestry || character.ancestryId || '-';
+ const heritage = translations?.heritage?.germanName || build?.heritage || character.heritageId || '-';
+ const background = translations?.background?.germanName || build?.background || character.backgroundId || '-';
+ const charClass = translations?.class?.germanName || build?.class || character.classId || '-';
+ const alignment = build?.alignment || '-';
+
+ // Get AC from pathbuilder
+ const ac = build?.acTotal?.acTotal || 10;
+
+ // Get speed from pathbuilder
+ const speed = build?.attributes?.speed || 25;
+
+ // Calculate Perception correctly
+ // Perception = WIS mod + proficiency bonus + level (if trained+)
+ const proficiencies = build?.proficiencies || {};
+ const perceptionProf = proficiencies['perception'] || 2; // Default to trained
+ const perceptionLevelBonus = perceptionProf > 0 ? character.level : 0;
+ const perception = getAbilityMod(WIS) + perceptionLevelBonus + perceptionProf;
+ const perceptionProfName = perceptionProf === 0 ? 'Ungeübt' :
+ perceptionProf === 2 ? 'Geübt' :
+ perceptionProf === 4 ? 'Experte' :
+ perceptionProf === 6 ? 'Meister' :
+ perceptionProf === 8 ? 'Legendär' : 'Geübt';
+
+ // Check if tabs have content
+ const hasSpells = character.spells && character.spells.length > 0;
+ const hasAlchemy = character.alchemyState && (character.formulas?.length || 0) > 0;
+ const hasPreparedItems = character.preparedItems && character.preparedItems.length > 0;
+
+ // Group items by equipped status
+ const equippedItems = character.items?.filter(i => i.isEquipped) || [];
+ const carriedItems = character.items?.filter(i => !i.isEquipped) || [];
+
+ // Calculate total bulk
+ const totalBulk = character.items?.reduce((sum, item) => {
+ const bulk = item.equipment?.bulk;
+ if (!bulk) return sum;
+ if (bulk === 'L') return sum + 0.1;
+ const num = parseFloat(bulk);
+ return isNaN(num) ? sum : sum + num * item.quantity;
+ }, 0) || 0;
+
+ // Filter skills (exclude saves and perception)
+ const EXCLUDED_SKILLS = ['Fortitude', 'Reflex', 'Will', 'Perception'];
+ const filteredSkills = (character.skills || []).filter(
+ (skill) => !EXCLUDED_SKILLS.includes(skill.skillName)
+ );
+
+ // Calculate skill modifiers
+ const skillsWithMods = filteredSkills.map(skill => {
+ const skillData = SKILL_DATA[skill.skillName];
+ const germanName = skillData?.german || skill.skillName;
+ const linkedAbility = skillData?.ability || 'INT';
+
+ const abilityScore = getAbility(linkedAbility);
+ const abilityMod = getAbilityMod(abilityScore);
+ const profBonus = PROFICIENCY_BONUS[skill.proficiency] || 0;
+ const levelBonus = skill.proficiency !== 'UNTRAINED' ? character.level : 0;
+ const totalBonus = abilityMod + levelBonus + profBonus;
+
+ const isLore = skill.skillName.toLowerCase().includes('lore');
+ const displayName = isLore ? skill.skillName.replace(' Lore', '') + ' (Wissen)' : germanName;
+
+ return {
+ name: displayName,
+ modifier: totalBonus,
+ proficiency: skill.proficiency,
+ };
+ });
+
+ // Get save values from pathbuilder proficiencies
+ const getSaveTotal = (saveName: string, abilityMod: number) => {
+ const prof = proficiencies[saveName] || 2; // Default to trained
+ const levelBonus = prof > 0 ? character.level : 0;
+ return abilityMod + levelBonus + prof;
+ };
+
+ const getSaveProfName = (saveName: string) => {
+ const prof = proficiencies[saveName] || 2;
+ if (prof === 0) return 'Ungeübt';
+ if (prof === 2) return 'Geübt';
+ if (prof === 4) return 'Experte';
+ if (prof === 6) return 'Meister';
+ if (prof === 8) return 'Legendär';
+ return 'Geübt';
+ };
+
+ const saveReflex = getSaveTotal('reflex', getAbilityMod(DEX));
+ const saveFortitude = getSaveTotal('fortitude', getAbilityMod(CON));
+ const saveWill = getSaveTotal('will', getAbilityMod(WIS));
+
+ const reflexProfName = getSaveProfName('reflex');
+ const fortitudeProfName = getSaveProfName('fortitude');
+ const willProfName = getSaveProfName('will');
+
+ const html = `
+
+
+
+
+ ${character.name} - Charakterbogen
+
+
+
+ ${character.name}
+
+
+
+ Attribute
+
+
+
STR
+
${formatMod(getAbilityMod(STR))}
+
${STR}
+
+
+
DEX
+
${formatMod(getAbilityMod(DEX))}
+
${DEX}
+
+
+
CON
+
${formatMod(getAbilityMod(CON))}
+
${CON}
+
+
+
INT
+
${formatMod(getAbilityMod(INT))}
+
${INT}
+
+
+
WIS
+
${formatMod(getAbilityMod(WIS))}
+
${WIS}
+
+
+
CHA
+
${formatMod(getAbilityMod(CHA))}
+
${CHA}
+
+
+
+
+
+
Trefferpunkte
+
${character.hpCurrent} / ${character.hpMax}
+ ${character.hpTemp ? `
Temporär: ${character.hpTemp}
` : ''}
+
+
+
Rüstungsklasse
+
${ac}
+
+
+
Bewegung
+
${speed} ft
+
+
+
Wahrnehmung
+
${formatMod(perception)}
+
${perceptionProfName}
+
+
+
+ Rettungswürfe
+
+
+
Zähigkeit
+
${formatMod(saveFortitude)}
+
${fortitudeProfName}
+
+
+
Reflex
+
${formatMod(saveReflex)}
+
${reflexProfName}
+
+
+
Wille
+
${formatMod(saveWill)}
+
${willProfName}
+
+
+
+ ${character.conditions && character.conditions.length > 0 ? `
+ Zustände
+
+ ${character.conditions.map(c => `
+ ${c.nameGerman || c.name}${c.value ? ` ${c.value}` : ''}
+ `).join('')}
+
+ ` : ''}
+
+ Fertigkeiten
+
+ ${skillsWithMods.map(skill => `
+
+ ${skill.name}
+
+ ${formatMod(skill.modifier)}
+ (${getProficiencyLabel(skill.proficiency)})
+
+
+ `).join('')}
+
+
+ Talente
+ ${Object.entries(groupFeatsBySource(character.feats || [])).map(([source, feats]) => `
+ ${translateFeatSource(source)}
+
+ ${feats.map(feat => `
+
+ ${feat.nameGerman || feat.name}
+ Stufe ${feat.level || 1}
+
+ `).join('')}
+
+ `).join('')}
+
+
+
+ Ausrüstung
+ Traglast: ${totalBulk.toFixed(1)} Bulk
+
+ ${equippedItems.length > 0 ? `
+ Angelegt
+
+
+
+ | Gegenstand |
+ Menge |
+ Bulk |
+
+
+
+ ${equippedItems.map(item => `
+
+ | ${item.equipment?.nameGerman || item.equipment?.name || item.customName || '-'} |
+ ${item.quantity} |
+ ${formatBulk(item.equipment?.bulk)} |
+
+ `).join('')}
+
+
+ ` : ''}
+
+ ${carriedItems.length > 0 ? `
+ Mitgeführt
+
+
+
+ | Gegenstand |
+ Menge |
+ Bulk |
+
+
+
+ ${carriedItems.map(item => `
+
+ | ${item.equipment?.nameGerman || item.equipment?.name || item.customName || '-'} |
+ ${item.quantity} |
+ ${formatBulk(item.equipment?.bulk)} |
+
+ `).join('')}
+
+
+ ` : ''}
+
+ ${hasSpells ? `
+ Zauber
+
+ ${Object.entries(
+ (character.spells || []).reduce((acc, spell) => {
+ const level = spell.spellLevel || 0;
+ if (!acc[level]) acc[level] = [];
+ acc[level].push(spell);
+ return acc;
+ }, {} as Record
)
+ ).sort(([a], [b]) => Number(a) - Number(b)).map(([level, spells]) => `
+
+
${level === '0' ? 'Zaubertricks' : `Grad ${level}`}
+
+ ${spells!.map(spell => `
+
${spell.nameGerman || spell.name}
+ `).join('')}
+
+
+ `).join('')}
+
+ ` : ''}
+
+ ${hasAlchemy || hasPreparedItems ? `
+ Alchemie
+
+ ${character.alchemyState ? `
+
+
+
Vielseitige Phiolen
+
${character.alchemyState.versatileVialsCurrent} / ${character.alchemyState.versatileVialsMax}
+
+
+
Fortgeschrittene Alchemie
+
${character.alchemyState.advancedAlchemyBatch} / ${character.alchemyState.advancedAlchemyMax}
+
Chargen verwendet
+
+
+ ` : ''}
+
+ ${(character.formulas?.length || 0) > 0 ? `
+
Formelbuch
+
+ ` : ''}
+
+ ${hasPreparedItems ? `
+
Vorbereitete Gegenstände
+
+
+
+ | Gegenstand |
+ Menge |
+ Typ |
+
+
+
+ ${character.preparedItems!.map(item => `
+
+ | ${item.equipment?.nameGerman || item.nameGerman || item.name} |
+ ${item.quantity} |
+ ${item.isInfused ? 'Infundiert' : 'Hergestellt'} |
+
+ `).join('')}
+
+
+ ` : ''}
+
+ ` : ''}
+
+
+
+`;
+
+ return html;
+}
+
+export function downloadCharacterHTML(character: Character): void {
+ const html = generateCharacterHTML(character);
+ const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `${character.name.replace(/[^a-zA-Z0-9äöüÄÖÜß\s-]/g, '')}_Charakterbogen.html`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ URL.revokeObjectURL(url);
+}