From 6f6ee41f0f5ee4ec9cc0da54915d2e29a602e7f2 Mon Sep 17 00:00:00 2001 From: WBHarry Date: Mon, 19 Jan 2026 21:47:00 +0100 Subject: [PATCH] Fixed so that companions can get bonus levelupchoices from their partner --- .../applications/levelup/companionLevelup.mjs | 6 +- .../applications/sheets/actors/companion.mjs | 9 - module/data/actor/character.mjs | 5 + module/data/actor/companion.mjs | 17 +- module/data/companionLevelup.mjs | 370 ++++++++++++++++++ module/documents/actor.mjs | 5 + templates/sheets/actors/companion/header.hbs | 4 +- 7 files changed, 402 insertions(+), 14 deletions(-) create mode 100644 module/data/companionLevelup.mjs diff --git a/module/applications/levelup/companionLevelup.mjs b/module/applications/levelup/companionLevelup.mjs index 4b8f9b47..7f11ccff 100644 --- a/module/applications/levelup/companionLevelup.mjs +++ b/module/applications/levelup/companionLevelup.mjs @@ -1,6 +1,6 @@ import BaseLevelUp from './levelup.mjs'; import { defaultCompanionTier, LevelOptionType } from '../../data/levelTier.mjs'; -import { DhLevelup } from '../../data/levelup.mjs'; +import { DhCompanionLevelup as DhLevelup } from '../../data/companionLevelup.mjs'; import { diceTypes, range } from '../../config/generalConfig.mjs'; export default class DhCompanionLevelUp extends BaseLevelUp { @@ -9,7 +9,9 @@ export default class DhCompanionLevelUp extends BaseLevelUp { this.levelTiers = this.addBonusChoices(defaultCompanionTier); const playerLevelupData = actor.system.levelData; - this.levelup = new DhLevelup(DhLevelup.initializeData(this.levelTiers, playerLevelupData)); + this.levelup = new DhLevelup( + DhLevelup.initializeData(this.levelTiers, playerLevelupData, actor.system.levelupChoicesLeft) + ); } async _preparePartContext(partId, context) { diff --git a/module/applications/sheets/actors/companion.mjs b/module/applications/sheets/actors/companion.mjs index 9b85f622..6f3bbf16 100644 --- a/module/applications/sheets/actors/companion.mjs +++ b/module/applications/sheets/actors/companion.mjs @@ -38,15 +38,6 @@ export default class DhCompanionSheet extends DHBaseActorSheet { } }; - /** @inheritDoc */ - async _onRender(context, options) { - await super._onRender(context, options); - - this.element - .querySelector('.level-value') - ?.addEventListener('change', event => this.document.updateLevel(Number(event.currentTarget.value))); - } - /* -------------------------------------------- */ /* Application Clicks Actions */ /* -------------------------------------------- */ diff --git a/module/data/actor/character.mjs b/module/data/actor/character.mjs index a7f99ca8..9132620a 100644 --- a/module/data/actor/character.mjs +++ b/module/data/actor/character.mjs @@ -651,6 +651,11 @@ export default class DhCharacter extends BaseDataActor { const globalHopeMax = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).maxHope; this.resources.hope.max = globalHopeMax - this.scars; this.resources.hitPoints.max += this.class.value?.system?.hitPoints ?? 0; + + /* Companion Related Data */ + this.companionData = { + levelupChoices: this.levelData.level.current - 1 + }; } prepareDerivedData() { diff --git a/module/data/actor/companion.mjs b/module/data/actor/companion.mjs index fa1965bd..51113452 100644 --- a/module/data/actor/companion.mjs +++ b/module/data/actor/companion.mjs @@ -108,7 +108,11 @@ export default class DhCompanion extends BaseDataActor { get proficiency() { return this.partner?.system?.proficiency ?? 1; } - + + get canLevelUp() { + return this.levelupChoicesLeft > 0; + } + isItemValid() { return false; } @@ -147,6 +151,17 @@ export default class DhCompanion extends BaseDataActor { } } + prepareDerivedData() { + /* Partner Related Setup */ + if (this.partner) { + this.levelData.level.changed = this.partner.system.levelData.level.current; + this.levelupChoicesLeft = Object.values(this.levelData.levelups).reduce((acc, curr) => { + acc = Math.max(acc - curr.selections.length, 0); + return acc; + }, this.partner.system.companionData.levelupChoices); + } + } + async _preUpdate(changes, options, userId) { const allowed = await super._preUpdate(changes, options, userId); if (allowed === false) return; diff --git a/module/data/companionLevelup.mjs b/module/data/companionLevelup.mjs new file mode 100644 index 00000000..7ab61210 --- /dev/null +++ b/module/data/companionLevelup.mjs @@ -0,0 +1,370 @@ +import { abilities } from '../config/actorConfig.mjs'; +import { chunkify } from '../helpers/utils.mjs'; +import { LevelOptionType } from './levelTier.mjs'; + +export class DhCompanionLevelup extends foundry.abstract.DataModel { + static initializeData(levelTierData, pcLevelData, origChoicesLeft) { + let choicesLeft = origChoicesLeft; + + const { current, changed } = pcLevelData.level; + const bonusChoicesOnly = current === changed; + const startLevel = bonusChoicesOnly ? current : current + 1; + const endLevel = bonusChoicesOnly ? startLevel : changed; + + const tiers = {}; + const levels = {}; + const tierKeys = Object.keys(levelTierData.tiers); + tierKeys.forEach(key => { + const tier = levelTierData.tiers[key]; + const belongingLevels = []; + for (var i = tier.levels.start; i <= tier.levels.end; i++) { + if (i <= endLevel) { + const initialAchievements = i === tier.levels.start ? tier.initialAchievements : {}; + const experiences = initialAchievements.experience + ? [...Array(initialAchievements.experience.nr).keys()].reduce((acc, _) => { + acc[foundry.utils.randomID()] = { + name: '', + modifier: initialAchievements.experience.modifier + }; + return acc; + }, {}) + : {}; + + const currentChoices = pcLevelData.levelups[i]?.selections?.length; + const maxSelections = + i === endLevel + ? choicesLeft + (currentChoices ?? 0) + : (currentChoices ?? tier.maxSelections[i]); + if (!pcLevelData.levelups[i]) choicesLeft -= maxSelections; + + levels[i] = DhLevelupLevel.initializeData(pcLevelData.levelups[i], maxSelections, { + ...initialAchievements, + experiences, + domainCards: {} + }); + } + + belongingLevels.push(i); + } + + /* Improve. Temporary handling for Companion new experiences */ + Object.keys(tier.extraAchievements ?? {}).forEach(key => { + const level = Number(key); + if (level >= startLevel && level <= endLevel) { + const levelExtras = tier.extraAchievements[level]; + if (levelExtras.experience) { + levels[level].achievements.experiences[foundry.utils.randomID()] = { + name: '', + modifier: levelExtras.experience.modifier + }; + } + } + }); + + tiers[key] = { + name: tier.name, + belongingLevels: belongingLevels, + options: Object.keys(tier.options).reduce((acc, key) => { + acc[key] = tier.options[key].toObject?.() ?? tier.options[key]; + return acc; + }, {}) + }; + }); + + return { + tiers, + levels, + startLevel, + currentLevel: startLevel, + endLevel + }; + } + + static defineSchema() { + const fields = foundry.data.fields; + + return { + tiers: new fields.TypedObjectField( + new fields.SchemaField({ + name: new fields.StringField({ required: true }), + belongingLevels: new fields.ArrayField(new fields.NumberField({ required: true, integer: true })), + options: new fields.TypedObjectField( + new fields.SchemaField({ + label: new fields.StringField({ required: true }), + checkboxSelections: new fields.NumberField({ required: true, integer: true }), + minCost: new fields.NumberField({ required: true, integer: true }), + type: new fields.StringField({ required: true, choices: LevelOptionType }), + value: new fields.NumberField({ integer: true }), + amount: new fields.NumberField({ integer: true }) + }) + ) + }) + ), + levels: new fields.TypedObjectField(new fields.EmbeddedDataField(DhLevelupLevel)), + startLevel: new fields.NumberField({ required: true, integer: true }), + currentLevel: new fields.NumberField({ required: true, integer: true }), + endLevel: new fields.NumberField({ required: true, integer: true }) + }; + } + + #levelFinished(levelKey) { + const allSelectionsMade = this.levels[levelKey].nrSelections.available === 0; + const allChoicesMade = Object.keys(this.levels[levelKey].choices).every(choiceKey => { + const choice = this.levels[levelKey].choices[choiceKey]; + return Object.values(choice).every(checkbox => { + switch (choiceKey) { + case 'trait': + case 'experience': + case 'domainCard': + case 'subclass': + case 'vicious': + return checkbox.data.length === (checkbox.amount ?? 1); + case 'multiclass': + const classSelected = checkbox.data.length === 1; + const domainSelected = checkbox.secondaryData.domain; + const subclassSelected = checkbox.secondaryData.subclass; + return classSelected && domainSelected && subclassSelected; + default: + return true; + } + }); + }); + const experiencesSelected = !this.levels[levelKey].achievements.experiences + ? true + : Object.values(this.levels[levelKey].achievements.experiences).every(exp => exp.name); + const domainCardsSelected = Object.values(this.levels[levelKey].achievements.domainCards) + .filter(x => x.level <= this.endLevel) + .every(card => card.uuid); + const allAchievementsSelected = experiencesSelected && domainCardsSelected; + + return allSelectionsMade && allChoicesMade && allAchievementsSelected; + } + + get currentLevelFinished() { + return this.#levelFinished(this.currentLevel); + } + + get allLevelsFinished() { + return Object.keys(this.levels) + .filter(level => Number(level) >= this.startLevel) + .every(this.#levelFinished.bind(this)); + } + + get unmarkedTraits() { + const possibleLevels = Object.values(this.tiers).reduce((acc, tier) => { + if (tier.belongingLevels.includes(this.currentLevel)) acc = tier.belongingLevels; + return acc; + }, []); + + return Object.keys(this.levels) + .filter(key => possibleLevels.some(x => x === Number(key))) + .reduce( + (acc, levelKey) => { + const level = this.levels[levelKey]; + Object.values(level.choices).forEach(choice => + Object.values(choice).forEach(checkbox => { + if ( + checkbox.type === 'trait' && + checkbox.data.length > 0 && + Number(levelKey) !== this.currentLevel + ) { + checkbox.data.forEach(data => delete acc[data]); + } + }) + ); + + return acc; + }, + { ...abilities } + ); + } + + get classUpgradeChoices() { + let subclasses = []; + let multiclass = null; + Object.keys(this.levels).forEach(levelKey => { + const level = this.levels[levelKey]; + Object.values(level.choices).forEach(choice => { + Object.values(choice).forEach(checkbox => { + if (checkbox.type === 'multiclass') { + multiclass = { + class: checkbox.data.length > 0 ? checkbox.data[0] : null, + domain: checkbox.secondaryData.domain ?? null, + subclass: checkbox.secondaryData.subclass ?? null, + tier: checkbox.tier, + level: levelKey + }; + } + if (checkbox.type === 'subclass') { + subclasses.push({ + tier: checkbox.tier, + level: levelKey + }); + } + }); + }); + }); + return { subclasses, multiclass }; + } + + get tiersForRendering() { + const tierKeys = Object.keys(this.tiers); + const selections = Object.keys(this.levels).reduce( + (acc, key) => { + const level = this.levels[key]; + Object.keys(level.choices).forEach(optionKey => { + const choice = level.choices[optionKey]; + Object.keys(choice).forEach(checkboxNr => { + const checkbox = choice[checkboxNr]; + if (!acc[checkbox.tier][optionKey]) acc[checkbox.tier][optionKey] = {}; + Object.keys(choice).forEach(checkboxNr => { + acc[checkbox.tier][optionKey][checkboxNr] = { ...checkbox, level: Number(key) }; + }); + }); + }); + + return acc; + }, + tierKeys.reduce((acc, key) => { + acc[key] = {}; + return acc; + }, {}) + ); + + const { multiclass, subclasses } = this.classUpgradeChoices; + return tierKeys.map((tierKey, tierIndex) => { + const tier = this.tiers[tierKey]; + const multiclassInTier = multiclass?.tier === Number(tierKey); + const subclassInTier = subclasses.some(x => x.tier === Number(tierKey)); + + return { + name: game.i18n.localize(tier.name), + active: this.currentLevel >= Math.min(...tier.belongingLevels), + groups: Object.keys(tier.options).map(optionKey => { + const option = tier.options[optionKey]; + + const checkboxes = [...Array(option.checkboxSelections).keys()].flatMap(index => { + const checkboxNr = index + 1; + const checkboxData = selections[tierKey]?.[optionKey]?.[checkboxNr]; + const checkbox = { ...option, checkboxNr, tier: tierKey }; + + if (checkboxData) { + checkbox.level = checkboxData.level; + checkbox.selected = true; + checkbox.disabled = checkbox.level !== this.currentLevel; + } + + if (optionKey === 'multiclass') { + if ((multiclass && !multiclassInTier) || subclassInTier) { + checkbox.disabled = true; + } + } + + if (optionKey === 'subclass' && multiclassInTier) { + checkbox.disabled = true; + } + + return checkbox; + }); + + let label = game.i18n.localize(option.label); + if (optionKey === 'domainCard') { + const maxLevel = tier.belongingLevels[tier.belongingLevels.length - 1]; + label = game.i18n.format(option.label, { maxLevel }); + } + + return { + label: label, + checkboxGroups: chunkify(checkboxes, option.minCost, chunkedBoxes => { + const anySelected = chunkedBoxes.some(x => x.selected); + const anyDisabled = chunkedBoxes.some(x => x.disabled); + return { + multi: option.minCost > 1, + checkboxes: chunkedBoxes.map(x => ({ + ...x, + selected: anySelected, + disabled: anyDisabled + })) + }; + }) + }; + }) + }; + }); + } +} + +export class DhLevelupLevel extends foundry.abstract.DataModel { + static initializeData(levelData = { selections: [] }, maxSelections, achievements) { + return { + maxSelections: maxSelections, + achievements: { + experiences: levelData.achievements?.experiences ?? achievements.experiences ?? {}, + domainCards: levelData.achievements?.domainCards + ? levelData.achievements.domainCards.reduce((acc, card, index) => { + acc[index] = { ...card }; + return acc; + }, {}) + : (achievements.domainCards ?? {}), + proficiency: levelData.achievements?.proficiency ?? achievements.proficiency ?? null + }, + choices: levelData.selections.reduce((acc, data) => { + if (!acc[data.optionKey]) acc[data.optionKey] = {}; + acc[data.optionKey][data.checkboxNr] = { ...data }; + + return acc; + }, {}) + }; + } + + static defineSchema() { + const fields = foundry.data.fields; + + return { + maxSelections: new fields.NumberField({ required: true, integer: true }), + achievements: new fields.SchemaField({ + experiences: new fields.TypedObjectField( + new fields.SchemaField({ + name: new fields.StringField({ required: true }), + modifier: new fields.NumberField({ required: true, integer: true }) + }) + ), + domainCards: new fields.TypedObjectField( + new fields.SchemaField({ + uuid: new fields.StringField({ required: true, nullable: true, initial: null }), + itemUuid: new fields.StringField({ required: true }), + level: new fields.NumberField({ required: true, integer: true }) + }) + ), + proficiency: new fields.NumberField({ integer: true }) + }), + choices: new fields.TypedObjectField( + new fields.TypedObjectField( + new fields.SchemaField({ + tier: new fields.NumberField({ required: true, integer: true }), + minCost: new fields.NumberField({ required: true, integer: true }), + amount: new fields.NumberField({ integer: true }), + value: new fields.StringField(), + data: new fields.ArrayField(new fields.StringField()), + secondaryData: new fields.TypedObjectField(new fields.StringField()), + type: new fields.StringField({ required: true }) + }) + ) + ) + }; + } + + get nrSelections() { + const selections = Object.keys(this.choices).reduce((acc, choiceKey) => { + const choice = this.choices[choiceKey]; + acc += Object.values(choice).reduce((acc, x) => acc + x.minCost, 0); + + return acc; + }, 0); + + return { + selections: selections, + available: this.maxSelections - selections + }; + } +} diff --git a/module/documents/actor.mjs b/module/documents/actor.mjs index cec1a24d..1616c496 100644 --- a/module/documents/actor.mjs +++ b/module/documents/actor.mjs @@ -241,6 +241,11 @@ export default class DhpActor extends Actor { } } }); + + if (this.system.companion) { + this.system.companion.updateLevel(usedLevel); + } + this.sheet.render(); } } diff --git a/templates/sheets/actors/companion/header.hbs b/templates/sheets/actors/companion/header.hbs index a543f71c..9688b7e9 100644 --- a/templates/sheets/actors/companion/header.hbs +++ b/templates/sheets/actors/companion/header.hbs @@ -31,7 +31,7 @@

{{localize 'DAGGERHEART.GENERAL.level'}}
- {{#if document.system.levelData.canLevelUp}} + {{#if document.system.canLevelUp}}