diff --git a/src/alchemy.d.ts b/src/alchemy.d.ts index 0b7991a..ee5a658 100644 --- a/src/alchemy.d.ts +++ b/src/alchemy.d.ts @@ -1,5 +1,6 @@ export interface AlchemyCharacter { abilityScores: AlchemyStat[], + actions?: AlchemyAction[], age?: string, armorClass: number, copper?: number, @@ -95,7 +96,7 @@ interface AlchemyCustomSpell { castingTime: string, components: string[], duration: string, - damage?: AlchemyDamage[], + damage?: AlchemyDiceRoll[], higherLevelDescription: string, higherLevels: AlchemySpellAtHigherLevel[], range: string, @@ -110,13 +111,7 @@ interface AlchemyCustomSpell { interface AlchemySpellAtHigherLevel { applyAtLevels: number[], - damage: AlchemyDamage, - type: string, -} - -interface AlchemyDamage { - bonus?: number, - dice: string, + damage: AlchemyDiceRoll, type: string, } @@ -138,3 +133,49 @@ interface AlchemyMovementMode { mode: string, distance: number, } + +interface AlchemyAction { + name: string, + description: string, + sortOrder?: number, + steps: AlchemyActionStep[], +} + +interface AlchemyActionStep { + type: string, + journalCommand?: AlchemyActionStepJournalCommand, + diceRoll?: AlchemyDiceRoll[], + attack?: AlchemyAttack, + skillCheck?: AlchemySkillCheck, +} + +interface AlchemyActionStepJournalCommand { + command: string, + args: string, +} + +interface AlchemyDiceRoll { + abilityName?: string, + bonus?: number, + dice: string, + type?: string, +} + +interface AlchemyAttack { + ability: string, + bonus?: number, + crit: number, + damageRolls: AlchemyDiceRoll[], + isProficient: boolean, + isRanged: boolean, + name: string, + range?: number, + longRange?: number, + rollsAttack?: boolean, + savingThrow?: AlchemySavingThrow, +} + +interface AlchemySkillCheck { + skillName: string, + rollModifier: string, +} diff --git a/src/convert.ts b/src/convert.ts index a555a7f..562a08e 100644 --- a/src/convert.ts +++ b/src/convert.ts @@ -1,5 +1,5 @@ -import { DdbArmorType, DdbModifier, DdbCharacter, DdbProficiencyType, DdbSpell, DdbSpellActivationType, DDB_SPEED_IS_RE, DDB_SPEED_EQUALS_RE, DDB_SPELL_ACTIVATION_TYPE, DDB_SPELL_COMPONENT_TYPE } from "./ddb" -import { AlchemyCharacter, AlchemyStat, AlchemyClass, AlchemyProficiency, AlchemyMovementMode, AlchemyTextBlockSection, AlchemySkill, AlchemyItem, AlchemySpellSlot, AlchemySpell, AlchemyDamage, AlchemySpellAtHigherLevel } from "./alchemy" +import { DdbArmorType, DdbModifier, DdbCharacter, DdbEntityType, DdbActivationType, DDB_ACTIVATION_TYPE, DdbItem, DdbNoteTypeId, DdbStatType, DdbAttackType, DdbAction, DDB_WEAPON_CATEGORY, DdbSpell, DDB_SPEED_IS_RE, DDB_SPEED_EQUALS_RE, DDB_SPELL_COMPONENT_TYPE } from "./ddb" +import { AlchemyCharacter, AlchemyStat, AlchemyClass, AlchemyProficiency, AlchemyMovementMode, AlchemyTextBlockSection, AlchemySkill, AlchemyItem, AlchemySpellSlot, AlchemySpell, AlchemyAction, AlchemyDiceRoll } from "./alchemy" import TurndownService from 'turndown' import * as turndownPluginGfm from 'turndown-plugin-gfm' @@ -172,6 +172,7 @@ turndownService.use(turndownPluginGfm.gfm) export const convertCharacter = (ddbCharacter: DdbCharacter): AlchemyCharacter => ({ abilityScores: convertStatArray(ddbCharacter), ...(ddbCharacter.age) && { age: ddbCharacter.age.toString() }, + actions: convertActions(ddbCharacter), armorClass: getArmorClass(ddbCharacter), copper: ddbCharacter.currencies.cp, classes: convertClasses(ddbCharacter), @@ -427,7 +428,7 @@ const convertProficiencies = (ddbCharacter: DdbCharacter): AlchemyProficiency[] // weapons proficiencies.push( getModifiers(ddbCharacter, { type: "proficiency" }) - .filter(modifier => modifier.entityTypeId === DdbProficiencyType.Weapon) + .filter(modifier => modifier.entityTypeId === DdbEntityType.Weapon) .map(modifier => ({ name: modifier.friendlySubtypeName, type: "weapon", @@ -437,7 +438,7 @@ const convertProficiencies = (ddbCharacter: DdbCharacter): AlchemyProficiency[] // tools proficiencies.push( getModifiers(ddbCharacter, { type: "proficiency" }) - .filter(modifier => modifier.entityTypeId === DdbProficiencyType.Tool) + .filter(modifier => modifier.entityTypeId === DdbEntityType.Tool) .map(modifier => ({ name: modifier.friendlySubtypeName, type: "tool", @@ -447,7 +448,7 @@ const convertProficiencies = (ddbCharacter: DdbCharacter): AlchemyProficiency[] // armor proficiencies.push( getModifiers(ddbCharacter, { type: "proficiency" }) - .filter(modifier => modifier.entityTypeId === DdbProficiencyType.Armor) + .filter(modifier => modifier.entityTypeId === DdbEntityType.Armor) .map(modifier => ({ name: modifier.friendlySubtypeName, type: "armor", @@ -463,7 +464,7 @@ const getSkills = (ddbCharacter: DdbCharacter): AlchemySkill[] => { const expertise = getModifiers(ddbCharacter, { type: "expertise" }) .map(modifier => modifier.friendlySubtypeName) const proficient = getModifiers(ddbCharacter, { type: "proficiency" }) - .filter(modifier => modifier.entityTypeId === DdbProficiencyType.Skill) + .filter(modifier => modifier.entityTypeId === DdbEntityType.Skill) .map(modifier => modifier.friendlySubtypeName) // Get all skills and set proficiency/expertise accordingly @@ -735,17 +736,14 @@ const convertSpell = (ddbSpell: DdbSpell): AlchemySpell => { const convertSpellCastingTime = (ddbSpell: DdbSpell): string => { const spell = ddbSpell.definition switch (spell.activation.activationType) { - case DdbSpellActivationType.Action: - case DdbSpellActivationType.BonusAction: - case DdbSpellActivationType.Reaction: - return `1 ${DDB_SPELL_ACTIVATION_TYPE[spell.activation.activationType]}` - case DdbSpellActivationType.Minute: - case DdbSpellActivationType.Hour: - case DdbSpellActivationType.Day: - case DdbSpellActivationType.LegendaryAction: - case DdbSpellActivationType.LairAction: + case DdbActivationType.Action: + case DdbActivationType.BonusAction: + case DdbActivationType.Reaction: + return `1 ${DDB_ACTIVATION_TYPE[spell.activation.activationType]}` + case DdbActivationType.Minute: + case DdbActivationType.Day: const s = spell.activation.activationTime > 1 ? "s" : "" - return `${spell.activation.activationTime} ${DDB_SPELL_ACTIVATION_TYPE[spell.activation.activationType]}${s}` + return `${spell.activation.activationTime} ${DDB_ACTIVATION_TYPE[spell.activation.activationType]}${s}` default: return "" } @@ -772,14 +770,18 @@ const convertSpellRange = (ddbSpell: DdbSpell): string => { } // Convert a spell's damage to Alchemy format -const convertSpellDamage = (ddbSpell: DdbSpell): AlchemyDamage[] => { +const convertSpellDamage = (ddbSpell: DdbSpell): AlchemyDiceRoll[] => { return ddbSpell.definition.modifiers .filter(modifier => modifier.type == "damage") - .map(modifier => ({ - type: modifier.friendlySubtypeName, - dice: `${modifier.die.diceCount}d${modifier.die.diceValue}`, - bonus: modifier.die.fixedValue, - })) + .map(modifier => { + const dice = modifier.die || modifier.dice + + return { + type: modifier.friendlySubtypeName, + dice: `${dice.diceCount}d${dice.diceValue}`, + bonus: dice.fixedValue, + } + }) } // Convert a spell's damage at higher levels to Alchemy format @@ -816,3 +818,136 @@ const convertSpellHigherLevels = (ddbSpell: DdbSpell): AlchemySpellAtHigherLevel } } */ + +// Convert anything that seems like an action to an Alchemy action +const convertActions = (ddbCharacter: DdbCharacter): AlchemyAction[] => { + const actions = [] + + // Add attack actions for any items that deal damage + ddbCharacter.inventory + .filter(item => item.definition.damage) + .map(item => actions.push(createItemAttackAction(ddbCharacter, item))) + + // Add actions for all other defined actions, from any source + Object.entries(ddbCharacter.actions) + .flatMap(([source, actions]) => actions) + .filter(action => action) + .map(action => actions.push(createGenericAction(action))) + + // Add a sortOrder to each action and return + return actions.map((action, index) => ({ + sortOrder: index, + ...action + })) +} + +const createItemAttackAction = (ddbCharacter: DdbCharacter, item: DdbItem): AlchemyAction => { + // Get the name, checking if it was set as a custom name + let itemName = item.definition.name + const itemNameNotes = ddbCharacter.characterValues + .filter(note => note.valueId == item.id.toString()) + .find(note => note.typeId == DdbNoteTypeId.Name) + if (itemNameNotes) { itemName = itemNameNotes.value.toString() } + + // Generate a name for the action with the item's type, if it's not obvious + const actionName = itemName == item.definition.type ? itemName : `${itemName} (${item.definition.type})` + + // Use str as the damage stat unless the item has finesse, in which case + // use the higher of str and dex + let attackAbility = "str" + if (item.definition.properties.find(prop => prop.name == "Finesse")) { + if (getStatBonus(ddbCharacter, DdbStatType.Dexterity) > getStatBonus(ddbCharacter, DdbStatType.Strength)) { + attackAbility = "dex" + } + } + + // Calculate damage dice, starting with base damage and type + const actionDamageRolls: AlchemyDiceRoll[] = [] + actionDamageRolls.push({ + type: item.definition.damageType, + dice: item.definition.damage.diceString, + bonus: item.definition.damage.fixedValue, + abilityName: attackAbility, + }) + item.definition.grantedModifiers + .filter(modifier => modifier.type == "damage") + .forEach(modifier => { + const dice = modifier.die || modifier.dice + + actionDamageRolls.push({ + type: modifier.friendlySubtypeName, + ...(dice) && { dice: dice.diceString }, + bonus: modifier.fixedValue || dice.fixedValue, + }) + }) + + // Check if proficient in weapon, weapon type ("light"), or weapon category ("martial") + const isProficientInWeapon = !!getModifiers(ddbCharacter, { type: "proficiency" }) + .filter(modifier => modifier.entityTypeId === DdbEntityType.Weapon) + .find(modifier => modifier.friendlySubtypeName === item.definition.type) + const isProficientInWeaponType = !!getModifiers(ddbCharacter, { type: "proficiency" }) + .filter(modifier => modifier.entityTypeId === DdbEntityType.WeaponType) + .find(modifier => { + const proficientType = modifier.friendlySubtypeName + const weaponTypes = item.definition.properties.map(prop => `${prop.name} Weapons`) + return weaponTypes.includes(proficientType) + }) + const isProficientInWeaponCategory = !!getModifiers(ddbCharacter, { type: "proficiency" }) + .filter(modifier => modifier.entityTypeId === DdbEntityType.WeaponType) + .find(modifier => { + const proficientType = modifier.friendlySubtypeName + const weaponCategory = `${DDB_WEAPON_CATEGORY[item.definition.categoryId]} Weapons` + return proficientType === weaponCategory + }) + + // Create the action + return { + name: actionName, + description: turndownService.turndown(item.definition.description || ""), + steps: [{ + attack: { + ability: attackAbility, + crit: 20, // no way to detect/set this in DDB right now + damageRolls: actionDamageRolls, + isProficient: isProficientInWeapon || isProficientInWeaponType || isProficientInWeaponCategory, + isRanged: item.definition.attackType == DdbAttackType.Ranged, + name: itemName, + }, + type: "custom-attack" + }] + } +} + +const createGenericAction = (ddbAction: DdbAction): AlchemyAction => { + const dice = ddbAction.dice || ddbAction.die + + // Add dice roll actions for any actions that can roll dice + if (dice) { + return { + name: ddbAction.name, + description: turndownService.turndown(ddbAction.description || ""), + steps: [{ + diceRoll: [{ + dice: dice.diceString, + bonus: dice.fixedValue, + }], + type: "custom-dice-roll" + }] + } + } + + // Add journal command actions for any other actions that can be activated + if (ddbAction.activation.activationType) { + return { + name: ddbAction.name, + description: turndownService.turndown(ddbAction.description || ""), + steps: [{ + journalCommand: { + command: "/me", + args: `uses ${ddbAction.name}!`, + }, + type: "journal-command" + }] + } + } +} diff --git a/src/ddb.ts b/src/ddb.ts index 572aaa6..3b46723 100644 --- a/src/ddb.ts +++ b/src/ddb.ts @@ -55,6 +55,13 @@ export interface DdbCharacter { feat: DdbModifier[], condition: DdbModifier[], }, + actions: { + race: DdbAction[], + class: DdbAction[], + background: DdbAction[], + item: DdbAction[], + feat: DdbAction[], + } spells: { race: DdbSpell[], class: DdbSpell[], @@ -66,6 +73,7 @@ export interface DdbCharacter { characterClassId: number; spells: DdbSpell[]; }[] + characterValues: DdbNote[], } interface DdbStat { @@ -101,16 +109,24 @@ export enum DdbArmorType { Shield } -export enum DdbProficiencyType { +export enum DdbEntityType { Tool = 2103445194, Skill = 1958004211, Weapon = 1782728300, + WeaponType = 660121713, Language = 906033267, Armor = 174869515, } -interface DdbItem { +export enum DdbAttackType { + Melee = 1, + Ranged +} + +export interface DdbItem { + id: number, definition: { + baseTypeId: DdbEntityType, magic: boolean, rarity: string, name: string, @@ -133,6 +149,8 @@ interface DdbItem { canAttune: boolean, attunementDescription: string, cost: number, + attackType: DdbAttackType, + categoryId: number, } quantity: number, isAttuned: boolean, @@ -149,8 +167,8 @@ export interface DdbModifier { friendlySubtypeName: string, friendlyTypeName: string, value: number, - dice: DdbDie[], - die: DdbDie, + dice?: DdbDie, + die?: DdbDie, atHigherLevels: { scaleType: string, higherLevelDefinitions: DdbHigherLevelDefinition[], @@ -205,7 +223,7 @@ export interface DdbSpell { }, activation: { activationTime: number, - activationType: DdbSpellActivationType, + activationType: DdbActivationType, }, range: { rangeValue: number, @@ -228,17 +246,13 @@ export interface DdbSpell { } } -export const DDB_SPELL_ACTIVATION_TYPE = { +export const DDB_ACTIVATION_TYPE = { 1: 'Action', - 2: 'Bonus Action', - 3: 'Reaction', - 4: 'Minute', - 5: 'Hour', - 6: 'Day', - 7: 'Special', - 8: 'Legendary Action', - 9: 'Lair Action', - 10: 'None', + 3: 'Bonus Action', + 4: 'Reaction', + 6: 'Minute', + 7: 'Day', + 8: 'Special', } interface DdbSource { @@ -247,17 +261,13 @@ interface DdbSource { sourceType: number } -export enum DdbSpellActivationType { +export enum DdbActivationType { Action = 1, - BonusAction, - Reaction, - Minute, - Hour, - Day, - Special, - LegendaryAction, - LairAction, - None, + BonusAction = 3, + Reaction = 4, + Minute = 6, + Day = 7, + Special = 8, } export const DDB_SPELL_COMPONENT_TYPE = { @@ -315,3 +325,60 @@ interface DdbFeat { description: string, } } + +interface DdbRange { + range: number, + longRange: number, + aoeType: number, + aoeSize: number, + minimumRange: number, +} + +export interface DdbAction { + name: string, + description: string, + snippet: string, + abilityModifierStatId: DdbStatType, + saveStatId: DdbStatType, + fixedSaveDc: number, + attackTypeRange: DdbAttackType, + attackType: DdbAttackType, + attackSubtype: string, + dice: DdbDie, + die?: DdbDie, + value: number, + isProficient: boolean, + displayAsAttack: boolean, + range: DdbRange, + activation: { + activationTime: number, + activationType: DdbActivationType, + }, + numberOfTargets: number, + fixedToHit: number, + limitedUse: { + name: string, + statModifierUsesId: DdbStatType, + resetType: number, + numberUsed: number, + maxUses: number, + } +} + +export const DDB_WEAPON_CATEGORY = { + 1: 'Simple', + 2: 'Martial', + 3: 'Exotic', +} + +interface DdbNote { + typeId: DdbNoteTypeId, + value: string | number, + notes: string, + valueId: string, + valueTypeId: DdbEntityType, +} + +export enum DdbNoteTypeId { + Name = 8, +}