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}

+ +
+
+ Abstammung + ${ancestry} +
+
+ Erbe + ${heritage} +
+
+ Hintergrund + ${background} +
+
+ Klasse + ${charClass} +
+
+ Stufe + ${character.level} +
+
+ Gesinnung + ${alignment} +
+
+ +

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

+ + + + + + + + + + ${equippedItems.map(item => ` + + + + + + `).join('')} + +
GegenstandMengeBulk
${item.equipment?.nameGerman || item.equipment?.name || item.customName || '-'}${item.quantity}${formatBulk(item.equipment?.bulk)}
+ ` : ''} + + ${carriedItems.length > 0 ? ` +

Mitgeführt

+ + + + + + + + + + ${carriedItems.map(item => ` + + + + + + `).join('')} + +
GegenstandMengeBulk
${item.equipment?.nameGerman || item.equipment?.name || item.customName || '-'}${item.quantity}${formatBulk(item.equipment?.bulk)}
+ ` : ''} + + ${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

+
+ ${character.formulas!.map(formula => ` +
+ ${formula.equipment?.nameGerman || formula.nameGerman || formula.name} + ${formula.equipment?.level !== undefined ? ` (Stufe ${formula.equipment.level})` : ''} +
+ `).join('')} +
+ ` : ''} + + ${hasPreparedItems ? ` +

Vorbereitete Gegenstände

+ + + + + + + + + + ${character.preparedItems!.map(item => ` + + + + + + `).join('')} + +
GegenstandMengeTyp
${item.equipment?.nameGerman || item.nameGerman || item.name}${item.quantity}${item.isInfused ? 'Infundiert' : 'Hergestellt'}
+ ` : ''} +
+ ` : ''} + + + +`; + + 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); +}