diff --git a/CHANGELOG.md b/CHANGELOG.md index b001d8b..3b47438 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0] - 2023-11-26 + +### Changed + +- Trackers (HP, XP) are now created in ddb2alchemy instead of in Alchemy itself. + ## [0.1.8] - 2023-10-12 ### Fixed diff --git a/package.json b/package.json index cbc1345..2a14ca5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ddb2alchemy", - "version": "0.1.8", + "version": "0.2.0", "description": "Convert D&D Beyond characters for use with the Alchemy VTT.", "main": "src/index.ts", "license": "MIT", diff --git a/src/alchemy.d.ts b/src/alchemy.d.ts index 5926e6c..d142ec9 100644 --- a/src/alchemy.d.ts +++ b/src/alchemy.d.ts @@ -4,9 +4,7 @@ export interface AlchemyCharacter { armorClass: number; copper?: number; classes: AlchemyClass[]; - currentHp: number; electrum?: number; - exp: number; eyes?: string; gold?: number; hair?: string; @@ -16,7 +14,6 @@ export interface AlchemyCharacter { items: AlchemyItem[]; isNPC: boolean; isSpellcaster: Boolean; - maxHp: number; movementModes: AlchemyMovementMode[]; name: string; platinum?: number; @@ -34,6 +31,7 @@ export interface AlchemyCharacter { spells: AlchemySpell[]; textBlocks: AlchemyTextBlockSection[]; weight?: string; + trackers?: AlchemyTracker[]; } interface AlchemyStat { @@ -138,3 +136,21 @@ interface AlchemyMovementMode { mode: string; distance: number; } + +interface AlchemyTracker { + name: string; + value: number; + max: number; + color: + | 'Blue' + | 'Green' + | 'Orange' + | 'Purple' + | 'Red' + | 'Theme Accent' + | 'Yellow'; + type: 'Bar' | 'Pip'; + category: 'health' | 'experience' | null; + sortOrder?: number; + readOnly?: boolean; +} diff --git a/src/convert.ts b/src/convert.ts index 0a7a6de..c1fd8f4 100644 --- a/src/convert.ts +++ b/src/convert.ts @@ -12,7 +12,8 @@ import { AlchemySpellSlot, AlchemyStat, AlchemyTextBlockSection, -} from './alchemy'; + AlchemyTracker, +} from './alchemy.d'; import { DDB_SPEED_EQUALS_RE, DDB_SPEED_IS_RE, @@ -25,6 +26,7 @@ import { DdbSpell, DdbSpellActivationType, } from './ddb'; +import { getExperienceRequiredForNextLevel } from './fifth-edition'; // Shared between both platforms const STR = 1; @@ -191,14 +193,11 @@ export const DEFAULT_ALCHEMY_CHARACTER: AlchemyCharacter = { abilityScores: [], armorClass: 0, classes: [], - currentHp: 0, - exp: 0, imageUri: '', initiativeBonus: 0, isNPC: false, isSpellcaster: false, items: [], - maxHp: 0, movementModes: [], name: '', proficiencies: [], @@ -251,9 +250,7 @@ export const convertCharacter = ( ...shouldConvert(options, 'armorClass', () => getArmorClass(ddbCharacter)), ...shouldConvert(options, 'copper', () => ddbCharacter.currencies.cp), ...shouldConvert(options, 'classes', () => convertClasses(ddbCharacter)), - ...shouldConvert(options, 'currentHp', () => getCurrentHp(ddbCharacter)), ...shouldConvert(options, 'electrum', () => ddbCharacter.currencies.ep), - ...shouldConvert(options, 'exp', () => ddbCharacter.currentXp), ...shouldConvert(options, 'eyes', () => ddbCharacter.eyes), ...shouldConvert(options, 'gold', () => ddbCharacter.currencies.gp), ...shouldConvert(options, 'hair', () => ddbCharacter.hair), @@ -270,7 +267,6 @@ export const convertCharacter = ( isSpellcaster(ddbCharacter), ), ...shouldConvert(options, 'items', () => convertItems(ddbCharacter)), - ...shouldConvert(options, 'maxHp', () => getMaxHp(ddbCharacter)), ...shouldConvert(options, 'movementModes', () => getMovementModes(ddbCharacter), ), @@ -303,6 +299,7 @@ export const convertCharacter = ( ...shouldConvert(options, 'weight', () => ddbCharacter.weight ? ddbCharacter.weight.toString() : '', ), + ...shouldConvert(options, 'trackers', () => convertTrackers(ddbCharacter)), }); // Convert D&D Beyond style stat arrays to Alchemy style stat arrays @@ -317,7 +314,7 @@ const convertStatArray = (ddbCharacter: DdbCharacter): AlchemyStat[] => { const getStatValue = (ddbCharacter: DdbCharacter, statId: number): number => { // Start with whatever the base stat is at level 1 const baseStatValue = - ddbCharacter.stats.find((stat) => stat.id === statId)?.value || + ddbCharacter.stats?.find((stat) => stat.id === statId)?.value || BASE_STAT; // If there are any overrides, use the highest of those instead of the base value @@ -345,7 +342,7 @@ const getModifiers = ( ddbCharacter: DdbCharacter, options: object, ): DdbModifier[] => { - return Object.values(ddbCharacter.modifiers) + return Object.values(ddbCharacter.modifiers || {}) .flat() .filter((modifier) => Object.keys(options).every((key) => modifier[key] === options[key]), @@ -354,7 +351,7 @@ const getModifiers = ( // Find all applicable modifiers based on keys/values in `options` and sum them const sumModifiers = (ddbCharacter: DdbCharacter, options: object): number => { - return getModifiers(ddbCharacter, options).reduce( + return getModifiers(ddbCharacter, options)?.reduce( (total, modifier) => total + modifier.value, 0, ); @@ -362,7 +359,7 @@ const sumModifiers = (ddbCharacter: DdbCharacter, options: object): number => { // Find all applicable modifiers based on keys/values in `options` and take the highest const maxModifier = (ddbCharacter: DdbCharacter, options: object): number => { - return getModifiers(ddbCharacter, options).reduce( + return getModifiers(ddbCharacter, options)?.reduce( (max, modifier) => Math.max(max, modifier.value), 0, ); @@ -430,7 +427,7 @@ const getArmorClass = (ddbCharacter: DdbCharacter): number => { // Calculate the base HP of the character, inclusive of bonus from CON modifier. const getBaseHp = (ddbCharacter: DdbCharacter): number => { const conBonus = getStatBonus(ddbCharacter, CON); - const levels = ddbCharacter.classes.reduce( + const levels = ddbCharacter.classes?.reduce( (total, c) => total + c.level, 0, ); @@ -1042,3 +1039,29 @@ const convertSpellHigherLevels = (ddbSpell: DdbSpell): AlchemySpellAtHigherLevel } } */ + +const convertTrackers = (ddbCharacter: DdbCharacter): AlchemyTracker[] => { + const totalExp = ddbCharacter.currentXp; + const nextLevelExp = getExperienceRequiredForNextLevel(totalExp); + + return [ + { + name: 'XP', + category: 'experience', + color: 'Yellow', + max: nextLevelExp, + value: totalExp, + type: 'Bar', + sortOrder: 0, + }, + { + name: 'HP', + category: 'health', + color: 'Green', + max: getMaxHp(ddbCharacter), + value: getCurrentHp(ddbCharacter), + type: 'Bar', + sortOrder: 0, + }, + ]; +}; diff --git a/src/ddb.ts b/src/ddb.ts index 83f767b..1d62531 100644 --- a/src/ddb.ts +++ b/src/ddb.ts @@ -49,8 +49,8 @@ export interface DdbCharacter { gp: number; pp: number; }; - classes: DdbClass[]; - modifiers: { + classes?: DdbClass[]; + modifiers?: { race: DdbModifier[]; class: DdbModifier[]; item: DdbModifier[]; diff --git a/src/fifth-edition.ts b/src/fifth-edition.ts new file mode 100644 index 0000000..25b934c --- /dev/null +++ b/src/fifth-edition.ts @@ -0,0 +1,51 @@ +/** + * Returns the experience required for the next level given a 5e character's + * total experience. + * @param currentExp The character's current total experience. + * @returns The amount of experience required for the next level. + */ +export const getExperienceRequiredForNextLevel = ( + currentExp: number, +): number => { + if (currentExp < 300) { + return 300; + } else if (currentExp < 900) { + return 900; + } else if (currentExp < 2700) { + return 2700; + } else if (currentExp < 6500) { + return 6500; + } else if (currentExp < 14000) { + return 14000; + } else if (currentExp < 23000) { + return 23000; + } else if (currentExp < 34000) { + return 34000; + } else if (currentExp < 48000) { + return 48000; + } else if (currentExp < 64000) { + return 64000; + } else if (currentExp < 85000) { + return 85000; + } else if (currentExp < 100000) { + return 100000; + } else if (currentExp < 120000) { + return 120000; + } else if (currentExp < 140000) { + return 140000; + } else if (currentExp < 165000) { + return 165000; + } else if (currentExp < 195000) { + return 195000; + } else if (currentExp < 225000) { + return 225000; + } else if (currentExp < 265000) { + return 265000; + } else if (currentExp < 305000) { + return 305000; + } else if (currentExp < 355000) { + return 355000; + } else { + return 0; + } +}; diff --git a/test/convert.currentHp.test.ts b/test/convert.currentHp.test.ts index 7626942..2503eb8 100644 --- a/test/convert.currentHp.test.ts +++ b/test/convert.currentHp.test.ts @@ -34,10 +34,12 @@ describe('Convert DDB current HP to Alchemy current HP', () => { ddbChar.overrideHitPoints = overrideHitPoints; const converted = convertCharacter(ddbChar as DdbCharacter, { - currentHp: true, + trackers: true, }); - expect(converted.currentHp).toEqual(overrideHitPoints); + expect( + converted.trackers?.find((t) => t.category === 'health')?.value, + ).toEqual(overrideHitPoints); }); test.each` @@ -74,10 +76,12 @@ describe('Convert DDB current HP to Alchemy current HP', () => { con; const converted = convertCharacter(ddbChar as DdbCharacter, { - currentHp: true, + trackers: true, }); - expect(converted.currentHp).toEqual(expected); + expect( + converted.trackers?.find((t) => t.category === 'health')?.value, + ).toEqual(expected); }, ); @@ -91,9 +95,11 @@ describe('Convert DDB current HP to Alchemy current HP', () => { ddbChar.classes.push({ level: 1 }); const converted = convertCharacter(ddbChar as DdbCharacter, { - currentHp: true, + trackers: true, }); - expect(converted.currentHp).toEqual(2); + expect( + converted.trackers?.find((t) => t.category === 'health')?.value, + ).toEqual(2); }); }); diff --git a/test/convert.exp.test.ts b/test/convert.exp.test.ts index 29fa6d9..d3855ca 100644 --- a/test/convert.exp.test.ts +++ b/test/convert.exp.test.ts @@ -1,25 +1,49 @@ import { describe, expect, test } from '@jest/globals'; import { convertCharacter } from '../src'; + import { DdbCharacter } from '../src/ddb'; import { DeepPartial } from './test-helpers'; -describe('Convert DDB currentXp to Alchemy exp', () => { +describe('Convert DDB currentXp to Alchemy tracker', () => { test.each` - currentXp | expected - ${10} | ${10} - ${0} | ${0} + currentXp | expectedValue | expectedMax + ${0} | ${0} | ${300} + ${300} | ${300} | ${900} + ${900} | ${900} | ${2700} + ${2700} | ${2700} | ${6500} + ${6500} | ${6500} | ${14000} + ${14000} | ${14000} | ${23000} + ${23000} | ${23000} | ${34000} + ${34000} | ${34000} | ${48000} + ${48000} | ${48000} | ${64000} + ${64000} | ${64000} | ${85000} + ${85000} | ${85000} | ${100000} + ${100000} | ${100000} | ${120000} + ${120000} | ${120000} | ${140000} + ${140000} | ${140000} | ${165000} + ${165000} | ${165000} | ${195000} + ${195000} | ${195000} | ${225000} + ${225000} | ${225000} | ${265000} + ${265000} | ${265000} | ${305000} + ${305000} | ${305000} | ${355000} + ${355000} | ${355000} | ${0} `( - 'returns exp=$expected when currentXp=$currentXp', - ({ currentXp, expected }) => { + 'returns tracker.value=$expectedValue and tracker.max=$expectedMax when currentXp=$currentXp', + ({ currentXp, expectedValue, expectedMax }) => { const ddbChar: DeepPartial = { currentXp, }; const converted = convertCharacter(ddbChar as DdbCharacter, { - exp: true, + trackers: true, }); - expect(converted.exp).toEqual(expected); + const expTracker = converted.trackers?.find( + (t) => t.category === 'experience', + ); + + expect(expTracker.value).toEqual(expectedValue); + expect(expTracker.max).toEqual(expectedMax); }, ); }); diff --git a/test/convert.maxHp.test.ts b/test/convert.maxHp.test.ts index 30884b5..cc8981b 100644 --- a/test/convert.maxHp.test.ts +++ b/test/convert.maxHp.test.ts @@ -48,10 +48,12 @@ describe('Convert DDB maxHP to Alchemy maxHP', () => { con; const converted = convertCharacter(ddbChar as DdbCharacter, { - maxHp: true, + trackers: true, }); - expect(converted.maxHp).toEqual(expected); + expect( + converted.trackers?.find((t) => t.category === 'health')?.max, + ).toEqual(expected); }, ); });