From b9914ebda5ce09da3bfc9e3c14ef4da3423dc834 Mon Sep 17 00:00:00 2001 From: "J. Garrett Vorbeck" Date: Wed, 11 Feb 2026 10:17:42 -0500 Subject: [PATCH 1/5] fix --- src/utils/character.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/utils/character.ts b/src/utils/character.ts index 26314cc9..acb54ec8 100644 --- a/src/utils/character.ts +++ b/src/utils/character.ts @@ -696,7 +696,14 @@ export function getEffectiveSpellcastingClass( return { type: "custom", classId: character.class }; } - const classData = getClassFromAvailable(character.class, availableClasses); + // First try to find in availableClasses (standard classes) + let classData = getClassFromAvailable(character.class, availableClasses); + + // If not found in availableClasses, check allClasses (handles combination classes) + if (!classData) { + classData = getClassById(character.class); + } + if (classData?.spellcasting) { return { type: "standard", classId: character.class }; } From 1a4f3507d0288591746c3e1b6ae9f182930f7af0 Mon Sep 17 00:00:00 2001 From: "J. Garrett Vorbeck" Date: Wed, 11 Feb 2026 10:20:17 -0500 Subject: [PATCH 2/5] code-review --- src/utils/character.ts | 55 +++++++++++++++++++++++++++--------------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/src/utils/character.ts b/src/utils/character.ts index acb54ec8..9a6474eb 100644 --- a/src/utils/character.ts +++ b/src/utils/character.ts @@ -573,17 +573,20 @@ export function areCurrentSpellsStillValid( return hasSpells(character); } - const classData = getClassFromAvailable(character.class, availableClasses); + // First try to find in availableClasses (standard classes) + let classData = getClassFromAvailable(character.class, availableClasses); + + // If not found in availableClasses, check allClasses (handles combination classes) + if (!classData) { + classData = getClassById(character.class); + } + if (!classData?.spellcasting) { return false; } // Map combination classes to their base spellcasting class for spell level lookups - let spellKeyToCheck = character.class; - if (character.class === CHARACTER_CLASSES.FIGHTER_MAGIC_USER || - character.class === CHARACTER_CLASSES.MAGIC_USER_THIEF) { - spellKeyToCheck = CHARACTER_CLASSES.MAGIC_USER; - } + const spellKeyToCheck = getSpellcastingBaseClass(character.class); // Check if all spells are valid for this class return character.spells.every((spell) => { @@ -605,20 +608,22 @@ export function hasRequiredStartingSpells( return spells.length >= 1; } - const classData = getClassFromAvailable(character.class, availableClasses); + // First try to find in availableClasses (standard classes) + let classData = getClassFromAvailable(character.class, availableClasses); + + // If not found in availableClasses, check allClasses (handles combination classes) + if (!classData) { + classData = getClassById(character.class); + } + if (!classData || !classData.spellcasting) return true; // Magic-user types require at least one starting spell (including combination classes) - if (classData.classType === CHARACTER_CLASSES.MAGIC_USER || - character.class === CHARACTER_CLASSES.FIGHTER_MAGIC_USER || - character.class === CHARACTER_CLASSES.MAGIC_USER_THIEF) { + if (classData.classType === CHARACTER_CLASSES.MAGIC_USER) { const spells = character.spells || []; - // For combination classes, use the base magic-user class to check spell levels - const spellKeyToCheck = (character.class === CHARACTER_CLASSES.FIGHTER_MAGIC_USER || - character.class === CHARACTER_CLASSES.MAGIC_USER_THIEF) - ? CHARACTER_CLASSES.MAGIC_USER - : character.class; + // For combination classes, use the base spellcasting class to check spell levels + const spellKeyToCheck = getSpellcastingBaseClass(character.class); const firstLevelSpells = spells.filter( (spell) => spell.level[spellKeyToCheck as keyof typeof spell.level] === 1 @@ -711,6 +716,20 @@ export function getEffectiveSpellcastingClass( return null; } +/** + * Maps combination classes to their base spellcasting class for spell level lookups. + * @param characterClass The class ID to map + * @returns The base spellcasting class ID (returns input if not a combination class) + */ +export function getSpellcastingBaseClass(characterClass: string): string { + const COMBINATION_TO_BASE_MAP: Record = { + [CHARACTER_CLASSES.FIGHTER_MAGIC_USER]: CHARACTER_CLASSES.MAGIC_USER, + [CHARACTER_CLASSES.MAGIC_USER_THIEF]: CHARACTER_CLASSES.MAGIC_USER, + [CHARACTER_CLASSES.ILLUSIONIST_THIEF]: CHARACTER_CLASSES.ILLUSIONIST, + }; + return COMBINATION_TO_BASE_MAP[characterClass] || characterClass; +} + export function getSpellcastingAbilityModifier(character: Character): number { const hasCustomSpellcaster = hasCustomClasses(character); @@ -889,11 +908,7 @@ export function getSpellLevel( } // Map combination classes to their base spellcasting class for spell level lookup - let mappedClassId = characterClass; - if (characterClass === CHARACTER_CLASSES.FIGHTER_MAGIC_USER || - characterClass === CHARACTER_CLASSES.MAGIC_USER_THIEF) { - mappedClassId = CHARACTER_CLASSES.MAGIC_USER; - } + const mappedClassId = getSpellcastingBaseClass(characterClass); const level = spell.level?.[mappedClassId as keyof typeof spell.level]; if (level != null) { From 726ea93e4420f3cb74bbec188a42f3dfe021bd51 Mon Sep 17 00:00:00 2001 From: "J. Garrett Vorbeck" Date: Wed, 11 Feb 2026 10:28:53 -0500 Subject: [PATCH 3/5] sonarqube --- src/utils/character.ts | 132 ++++++++++++++++++++--------------------- 1 file changed, 66 insertions(+), 66 deletions(-) diff --git a/src/utils/character.ts b/src/utils/character.ts index 9a6474eb..f343f6bd 100644 --- a/src/utils/character.ts +++ b/src/utils/character.ts @@ -33,8 +33,8 @@ const parseShieldBonus = ( itemName: string ): number => { if (typeof shieldAC === "string" && shieldAC.startsWith("+")) { - const bonusValue = parseInt(shieldAC.substring(1), 10); - if (isNaN(bonusValue)) { + const bonusValue = Number.parseInt(shieldAC.substring(1), 10); + if (Number.isNaN(bonusValue)) { logger.warn(`Invalid shield AC value for ${itemName}: ${shieldAC}`); return 0; } @@ -379,12 +379,14 @@ function applyHitDiceRestriction( currentHitDie: string, maxSizeRestriction: string ): string { - const classMatch = currentHitDie.match(/\d*d(\d+)/); - const restrictedMatch = maxSizeRestriction.match(/d(\d+)/); + const classRegex = /\d*d(\d+)/; + const restrictedRegex = /d(\d+)/; + const classMatch = classRegex.exec(currentHitDie); + const restrictedMatch = restrictedRegex.exec(maxSizeRestriction); if (classMatch?.[1] && restrictedMatch?.[1]) { - const classDieSize = parseInt(classMatch[1], 10); - const restrictedDieSize = parseInt(restrictedMatch[1], 10); + const classDieSize = Number.parseInt(classMatch[1], 10); + const restrictedDieSize = Number.parseInt(restrictedMatch[1], 10); if (restrictedDieSize < classDieSize) { return `1${maxSizeRestriction}`; @@ -395,10 +397,11 @@ function applyHitDiceRestriction( } function applyHitDiceDecrease(currentHitDie: string): string { - const match = currentHitDie.match(/\d*d(\d+)/); + const regex = /\d*d(\d+)/; + const match = regex.exec(currentHitDie); if (!match?.[1]) return currentHitDie; - const currentSize = parseInt(match[1], 10); + const currentSize = Number.parseInt(match[1], 10); let newSize: number; switch (currentSize) { @@ -425,10 +428,11 @@ function applyHitDiceDecrease(currentHitDie: string): string { } function applyHitDiceIncrease(currentHitDie: string): string { - const match = currentHitDie.match(/\d*d(\d+)/); + const regex = /\d*d(\d+)/; + const match = regex.exec(currentHitDie); if (!match?.[1]) return currentHitDie; - const currentSize = parseInt(match[1], 10); + const currentSize = Number.parseInt(match[1], 10); let newSize: number; switch (currentSize) { @@ -451,6 +455,38 @@ function applyHitDiceIncrease(currentHitDie: string): string { return `1d${newSize}`; } +function createModificationInfo( + abilityName: string, + originalHitDie: string, + modifiedHitDie: string | null, + modificationType: "restriction" | "decrease" | "increase" +): RacialModificationInfo { + return { + abilityName, + originalHitDie, + modifiedHitDie: modifiedHitDie || originalHitDie, + modificationType, + }; +} + +function checkHitDiceRestriction( + originalHitDie: string, + maxSize: string +): boolean { + const classRegex = /\d*d(\d+)/; + const restrictedRegex = /d(\d+)/; + const classMatch = classRegex.exec(originalHitDie); + const restrictedMatch = restrictedRegex.exec(maxSize); + + if (classMatch?.[1] && restrictedMatch?.[1]) { + const classDieSize = Number.parseInt(classMatch[1], 10); + const restrictedDieSize = Number.parseInt(restrictedMatch[1], 10); + return restrictedDieSize < classDieSize; + } + + return false; +} + export function getRacialModificationInfo( character: Character ): RacialModificationInfo | null { @@ -472,37 +508,16 @@ export function getRacialModificationInfo( const hitDiceRestriction = ability.effects?.hitDiceRestriction; const hitDiceBonus = ability.effects?.hitDiceBonus; - if (hitDiceRestriction?.maxSize) { - const classMatch = originalHitDie.match(/\d*d(\d+)/); - const restrictedMatch = hitDiceRestriction.maxSize.match(/d(\d+)/); - - if (classMatch?.[1] && restrictedMatch?.[1]) { - const classDieSize = parseInt(classMatch[1], 10); - const restrictedDieSize = parseInt(restrictedMatch[1], 10); - - if (restrictedDieSize < classDieSize) { - return { - abilityName: ability.name, - originalHitDie, - modifiedHitDie: modifiedHitDie || originalHitDie, - modificationType: "restriction", - }; - } - } - } else if (hitDiceRestriction?.sizeDecrease) { - return { - abilityName: ability.name, - originalHitDie, - modifiedHitDie: modifiedHitDie || originalHitDie, - modificationType: "decrease", - }; - } else if (hitDiceBonus?.sizeIncrease) { - return { - abilityName: ability.name, - originalHitDie, - modifiedHitDie: modifiedHitDie || originalHitDie, - modificationType: "increase", - }; + if (hitDiceRestriction?.maxSize && checkHitDiceRestriction(originalHitDie, hitDiceRestriction.maxSize)) { + return createModificationInfo(ability.name, originalHitDie, modifiedHitDie, "restriction"); + } + + if (hitDiceRestriction?.sizeDecrease) { + return createModificationInfo(ability.name, originalHitDie, modifiedHitDie, "decrease"); + } + + if (hitDiceBonus?.sizeIncrease) { + return createModificationInfo(ability.name, originalHitDie, modifiedHitDie, "increase"); } } @@ -573,13 +588,8 @@ export function areCurrentSpellsStillValid( return hasSpells(character); } - // First try to find in availableClasses (standard classes) - let classData = getClassFromAvailable(character.class, availableClasses); - - // If not found in availableClasses, check allClasses (handles combination classes) - if (!classData) { - classData = getClassById(character.class); - } + // First try to find in availableClasses (standard classes), fallback to allClasses (combination classes) + const classData = getClassFromAvailable(character.class, availableClasses) ?? getClassById(character.class); if (!classData?.spellcasting) { return false; @@ -608,15 +618,10 @@ export function hasRequiredStartingSpells( return spells.length >= 1; } - // First try to find in availableClasses (standard classes) - let classData = getClassFromAvailable(character.class, availableClasses); - - // If not found in availableClasses, check allClasses (handles combination classes) - if (!classData) { - classData = getClassById(character.class); - } + // First try to find in availableClasses (standard classes), fallback to allClasses (combination classes) + const classData = getClassFromAvailable(character.class, availableClasses) ?? getClassById(character.class); - if (!classData || !classData.spellcasting) return true; + if (!classData?.spellcasting) return true; // Magic-user types require at least one starting spell (including combination classes) if (classData.classType === CHARACTER_CLASSES.MAGIC_USER) { @@ -701,13 +706,8 @@ export function getEffectiveSpellcastingClass( return { type: "custom", classId: character.class }; } - // First try to find in availableClasses (standard classes) - let classData = getClassFromAvailable(character.class, availableClasses); - - // If not found in availableClasses, check allClasses (handles combination classes) - if (!classData) { - classData = getClassById(character.class); - } + // First try to find in availableClasses (standard classes), fallback to allClasses (combination classes) + const classData = getClassFromAvailable(character.class, availableClasses) ?? getClassById(character.class); if (classData?.spellcasting) { return { type: "standard", classId: character.class }; @@ -899,10 +899,10 @@ export function getSpellLevel( ): number { if (isCustomClass(characterClass)) { const validLevels = Object.values(spell.level).filter( - (level) => level !== null && level !== undefined + (level): level is number => level !== null && level !== undefined ); if (validLevels.length > 0) { - return Math.min(...(validLevels as number[])); + return Math.min(...validLevels); } return 1; } @@ -910,7 +910,7 @@ export function getSpellLevel( // Map combination classes to their base spellcasting class for spell level lookup const mappedClassId = getSpellcastingBaseClass(characterClass); - const level = spell.level?.[mappedClassId as keyof typeof spell.level]; + const level = spell.level?.[mappedClassId]; if (level != null) { return level; } From ea50decbd64fb9d6dda8e9cfce6cffdab403056e Mon Sep 17 00:00:00 2001 From: "J. Garrett Vorbeck" Date: Wed, 11 Feb 2026 10:34:35 -0500 Subject: [PATCH 4/5] bug fix equipment pack gold deduction --- src/components/features/character/creation/EquipmentStep.tsx | 5 +++-- src/hooks/equipment/useEquipmentManagement.ts | 1 + src/types/character.ts | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/features/character/creation/EquipmentStep.tsx b/src/components/features/character/creation/EquipmentStep.tsx index 7a2dea9e..eace2852 100644 --- a/src/components/features/character/creation/EquipmentStep.tsx +++ b/src/components/features/character/creation/EquipmentStep.tsx @@ -12,8 +12,7 @@ import { Button, Icon } from "@/components/ui"; import { Card, Typography, Badge } from "@/components/ui/core/display"; import { InfoCardHeader, StatGrid } from "@/components/ui/composite"; import { ErrorDisplay } from "@/components/ui/core/feedback"; -import type { BaseStepProps } from "@/types"; -import type { EquipmentPack } from "@/types"; +import type { BaseStepProps, EquipmentPack } from "@/types"; import { EquipmentSelector } from "@/components/domain/equipment"; import { useEquipmentManagement } from "@/hooks"; import { useCharacterMutations } from "@/hooks/mutations/useEnhancedMutations"; @@ -33,6 +32,7 @@ function EquipmentStep({ character, onCharacterChange }: EquipmentStepProps) { totalValue, cleanedEquipment, getStatusMessage, + setStartingGold, } = useEquipmentManagement(character, onCharacterChange); // Equipment pack state @@ -55,6 +55,7 @@ function EquipmentStep({ character, onCharacterChange }: EquipmentStepProps) { pack, }); onCharacterChange(updatedCharacter); + setStartingGold(updatedCharacter.currency.gold); setSelectedPack(pack); } catch { // Error is handled by the mutation hook diff --git a/src/hooks/equipment/useEquipmentManagement.ts b/src/hooks/equipment/useEquipmentManagement.ts index 3d0e2c04..4bb138b4 100644 --- a/src/hooks/equipment/useEquipmentManagement.ts +++ b/src/hooks/equipment/useEquipmentManagement.ts @@ -185,6 +185,7 @@ export function useEquipmentManagement( return { startingGold, + setStartingGold, handleGoldRoll, handleEquipmentAdd, handleEquipmentRemove, diff --git a/src/types/character.ts b/src/types/character.ts index cb39603a..b4243202 100644 --- a/src/types/character.ts +++ b/src/types/character.ts @@ -211,8 +211,8 @@ export interface Class { // Base interface for character creation step props export interface BaseStepProps { - character: Character; - onCharacterChange: (character: Character) => void; + readonly character: Character; + readonly onCharacterChange: (character: Character) => void; } // Game rules type exports - derived from gameRules constants From 7d4dea0ba30570f411e15118cfc74654e697177e Mon Sep 17 00:00:00 2001 From: "J. Garrett Vorbeck" Date: Wed, 11 Feb 2026 10:42:09 -0500 Subject: [PATCH 5/5] dex modifier to ac --- .claude/settings.local.json | 3 +- src/__tests__/utils/character.test.ts | 124 +++++++++++++++++++++++++- src/utils/character.ts | 35 +++++++- 3 files changed, 158 insertions(+), 4 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3fa9cef9..193608eb 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -39,7 +39,8 @@ "Bash(done)", "Bash(gh issue view:*)", "Bash(npm update:*)", - "WebFetch(domain:eslint.org)" + "WebFetch(domain:eslint.org)", + "Bash(npm test:*)" ], "deny": [ "Bash(npm run dev)" diff --git a/src/__tests__/utils/character.test.ts b/src/__tests__/utils/character.test.ts index 9da0acd3..6e0bd582 100644 --- a/src/__tests__/utils/character.test.ts +++ b/src/__tests__/utils/character.test.ts @@ -45,7 +45,7 @@ describe("Character Utilities", () => { ); }); - it("calculates AC with worn armor", () => { + it("calculates AC with worn armor (no dex modifier)", () => { const character = { equipment: [ { @@ -55,6 +55,14 @@ describe("Character Utilities", () => { category: "armor", } as Equipment, ], + abilities: { + strength: { value: 10, modifier: 0 }, + dexterity: { value: 10, modifier: 0 }, + constitution: { value: 10, modifier: 0 }, + intelligence: { value: 10, modifier: 0 }, + wisdom: { value: 10, modifier: 0 }, + charisma: { value: 10, modifier: 0 }, + }, }; expect(calculateArmorClass(character)).toBe(15); }); @@ -75,6 +83,14 @@ describe("Character Utilities", () => { category: "shield", } as Equipment, ], + abilities: { + strength: { value: 10, modifier: 0 }, + dexterity: { value: 10, modifier: 0 }, + constitution: { value: 10, modifier: 0 }, + intelligence: { value: 10, modifier: 0 }, + wisdom: { value: 10, modifier: 0 }, + charisma: { value: 10, modifier: 0 }, + }, }; expect(calculateArmorClass(character)).toBe(13); }); @@ -94,6 +110,112 @@ describe("Character Utilities", () => { GAME_MECHANICS.DEFAULT_UNARMORED_AC ); }); + + it("applies Dexterity modifier to AC", () => { + const character = { + equipment: [ + { + name: "Leather Armor", + AC: 13, + wearing: true, + category: "armor", + } as Equipment, + ], + abilities: { + strength: { value: 10, modifier: 0 }, + dexterity: { value: 16, modifier: 2 }, + constitution: { value: 10, modifier: 0 }, + intelligence: { value: 10, modifier: 0 }, + wisdom: { value: 10, modifier: 0 }, + charisma: { value: 10, modifier: 0 }, + }, + }; + // Leather armor (13) + Dex modifier (+2) = 15 + expect(calculateArmorClass(character)).toBe(15); + }); + + it("applies negative Dexterity modifier to AC", () => { + const character = { + equipment: [ + { + name: "Chain Mail", + AC: 15, + wearing: true, + category: "armor", + } as Equipment, + ], + abilities: { + strength: { value: 10, modifier: 0 }, + dexterity: { value: 8, modifier: -1 }, + constitution: { value: 10, modifier: 0 }, + intelligence: { value: 10, modifier: 0 }, + wisdom: { value: 10, modifier: 0 }, + charisma: { value: 10, modifier: 0 }, + }, + }; + // Chain mail (15) + Dex modifier (-1) = 14 + expect(calculateArmorClass(character)).toBe(14); + }); + + it("applies Dexterity modifier to unarmored AC", () => { + const character = { + equipment: [], + abilities: { + strength: { value: 10, modifier: 0 }, + dexterity: { value: 16, modifier: 2 }, + constitution: { value: 10, modifier: 0 }, + intelligence: { value: 10, modifier: 0 }, + wisdom: { value: 10, modifier: 0 }, + charisma: { value: 10, modifier: 0 }, + }, + }; + // Default AC (11) + Dex modifier (+2) = 13 + expect(calculateArmorClass(character)).toBe(13); + }); + + it("applies Dexterity modifier with armor and shield", () => { + const character = { + equipment: [ + { + name: "Leather Armor", + AC: 13, + wearing: true, + category: "armor", + } as Equipment, + { + name: "Shield", + AC: "+1", + wearing: true, + category: "shield", + } as Equipment, + ], + abilities: { + strength: { value: 10, modifier: 0 }, + dexterity: { value: 16, modifier: 2 }, + constitution: { value: 10, modifier: 0 }, + intelligence: { value: 10, modifier: 0 }, + wisdom: { value: 10, modifier: 0 }, + charisma: { value: 10, modifier: 0 }, + }, + }; + // Leather armor (13) + Shield (+1) + Dex modifier (+2) = 16 + expect(calculateArmorClass(character)).toBe(16); + }); + + it("handles characters without abilities property gracefully", () => { + const character = { + equipment: [ + { + name: "Chain Mail", + AC: 15, + wearing: true, + category: "armor", + } as Equipment, + ], + }; + // Chain mail (15) + no abilities = 15 (defaults to 0 modifier) + expect(calculateArmorClass(character)).toBe(15); + }); }); describe("Movement Rate Calculations", () => { diff --git a/src/utils/character.ts b/src/utils/character.ts index f343f6bd..a211f9a4 100644 --- a/src/utils/character.ts +++ b/src/utils/character.ts @@ -52,6 +52,14 @@ interface EquipmentLike { category?: string; } +interface AbilitiesLike { + dexterity?: { + modifier: number; + value?: number; + }; + [key: string]: unknown; +} + const isEquipmentArray = (equipment: unknown[]): equipment is Equipment[] => { return equipment.every((item): item is Equipment => typeof item === 'object' && @@ -61,8 +69,26 @@ const isEquipmentArray = (equipment: unknown[]): equipment is Equipment[] => { ); }; +/** + * Calculates the total Armor Class for a character + * + * Formula: Base AC (from armor or default 11) + Shield Bonus + Dexterity Modifier + * + * Per BFRPG rules (page 3): "Don't forget to add your Dexterity bonus or penalty to the figure." + * + * @param character - Character object or minimal combat data with equipment and abilities + * @returns The calculated Armor Class as a number + * + * @example + * // Leather armor (AC 13) with Dex +2 + * calculateArmorClass(character) // Returns 15 + * + * @example + * // Unarmored (AC 11) with Dex +2 + * calculateArmorClass(character) // Returns 13 + */ export function calculateArmorClass( - character: Character | { equipment?: EquipmentLike[] } + character: Character | { equipment?: EquipmentLike[]; abilities?: AbilitiesLike } ): number { const equipment = character.equipment; if (!Array.isArray(equipment)) { @@ -90,7 +116,12 @@ export function calculateArmorClass( ); }); - return baseAC + shieldBonus; + // Add Dexterity modifier to AC (BFRPG rules, page 3) + const dexModifier = 'abilities' in character + ? (character.abilities?.dexterity?.modifier ?? 0) + : 0; + + return baseAC + shieldBonus + dexModifier; } export function calculateMovementRate(character: Character): string {