Skip to content

Commit

Permalink
Merge pull request #3217 from foundryvtt/summoning-workflow
Browse files Browse the repository at this point in the history
[#893] Add summoning actor updates & overall workflow
  • Loading branch information
arbron authored Mar 15, 2024
2 parents 6ad3656 + 816902c commit 113581c
Show file tree
Hide file tree
Showing 7 changed files with 352 additions and 52 deletions.
3 changes: 3 additions & 0 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1467,6 +1467,9 @@
"Hint": "Modify to saving throw DCs on the summoned creature's abilities to match that of the summoner."
}
},
"Placement": {
"GenericError": "Failed to place summoned actor"
},
"Profile": {
"Label": "Summons Profile",
"LabelPl": "Summons Profiles",
Expand Down
2 changes: 1 addition & 1 deletion module/applications/item/summoning-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export default class SummoningConfig extends DocumentSheet {
* @type {object[]}
*/
get profiles() {
return this.document.system.summons.profiles;
return this.document.system.summons?.profiles ?? [];
}

/* -------------------------------------------- */
Expand Down
1 change: 1 addition & 0 deletions module/data/item/_module.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export {
WeaponData
};
export {default as ItemTypeField} from "./fields/item-type-field.mjs";
export {default as SummonsField, SummonsData} from "./fields/summons-field.mjs";
export {default as ActionTemplate} from "./templates/action.mjs";
export {default as ActivatedEffectTemplate} from "./templates/activated-effect.mjs";
export {default as EquippableItemTemplate} from "./templates/equippable-item.mjs";
Expand Down
321 changes: 321 additions & 0 deletions module/data/item/fields/summons-field.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
import { FormulaField } from "../../fields.mjs";

const {
ArrayField, BooleanField, DocumentIdField, NumberField, SchemaField, StringField
} = foundry.data.fields;

/**
* A field for storing summons data.
*
* @param {object} [options={}] Options to configure this field's behavior.
*/
export default class SummonsField extends foundry.data.fields.EmbeddedDataField {
constructor(options={}) {
super(SummonsData, foundry.utils.mergeObject({ required: false, nullable: true, initial: null }, options));
}
}

/**
* Information for a single summoned creature.
*
* @typedef {object} SummonsProfile
* @property {string} _id Unique ID for this profile.
* @property {number} count Number of creatures to summon.
* @property {string} name Display name for this profile if it differs from actor's name.
* @property {string} uuid UUID of the actor to summon.
*/

/**
* Data model for summons configuration.
*
* @property {object} bonuses
* @property {string} bonuses.ac Formula for armor class bonus on summoned actor.
* @property {string} bonuses.hp Formula for bonus hit points to add to each summoned actor.
* @property {object} match
* @property {boolean} match.attacks Match the to hit values on summoned actor's attack to the summoner.
* @property {boolean} match.proficiency Match proficiency on summoned actor to the summoner.
* @property {boolean} match.saves Match the save DC on summoned actor's abilities to the summoner.
* @property {SummonsProfile[]} profiles Information on creatures that can be summoned.
* @property {boolean} prompt Should the player be prompted to place the summons?
*/
export class SummonsData extends foundry.abstract.DataModel {
/** @inheritdoc */
static defineSchema() {
return {
bonuses: new SchemaField({
ac: new FormulaField({label: "DND5E.Summoning.ArmorClass.Label", hint: "DND5E.Summoning.ArmorClass.hint"}),
hp: new FormulaField({label: "DND5E.Summoning.HitPoints.Label", hint: "DND5E.Summoning.HitPoints.hint"})
}),
match: new SchemaField({
attacks: new BooleanField({
label: "DND5E.Summoning.Match.Attacks.Label", hint: "DND5E.Summoning.Match.Attacks.Hint"
}),
proficiency: new BooleanField({
label: "DND5E.Summoning.Match.Proficiency.Label", hint: "DND5E.Summoning.Match.Proficiency.Hint"
}),
saves: new BooleanField({
label: "DND5E.Summoning.Match.Saves.Label", hint: "DND5E.Summoning.Match.Saves.Hint"
})
}),
profiles: new ArrayField(new SchemaField({
_id: new DocumentIdField({initial: () => foundry.utils.randomID()}),
count: new NumberField({integer: true, min: 1}),
name: new StringField(),
uuid: new StringField()
})),
prompt: new BooleanField({
initial: true, label: "DND5E.Summoning.Prompt.Label", hint: "DND5E.Summoning.Prompt.Hint"
})
};
}

/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */

/**
* Item to which this summoning information belongs.
* @type {Item5e}
*/
get item() {
return this.parent.parent;
}

/* -------------------------------------------- */
/* Summoning */
/* -------------------------------------------- */

/**
* Process for summoning actor to the scene.
* @param {string} profileId ID of the summoning profile to use.
*/
async summon(profileId) {
const profile = this.profiles.find(p => p._id === profileId);
if ( !profile ) throw new Error(`Cannot find summoning profile ${profileId} on ${this.item.id}.`);

/**
* A hook event that fires before summoning is performed.
* @function dnd5e.preSummon
* @memberof hookEvents
* @param {Item5e} item The item that is performing the summoning.
* @param {SummonsProfile} profile Profile used for summoning.
* @returns {boolean} Explicitly return `false` to prevent summoning.
*/
if ( Hooks.call("dnd5e.preSummon", this.item, profile) === false ) return;

// Fetch the actor that will be summoned
let actor = await fromUuid(profile.uuid);

// TODO: Import actor into world if inside compendium

const tokensData = [];
const minimized = !this.item.parent?.sheet._minimized;
await this.item.parent?.sheet.minimize();
try {
// Figure out where to place the summons
const placements = await this.getPlacement(actor.prototypeToken, profile);

for ( const placement of placements ) {
// Prepare changes to actor data, re-calculating per-token for potentially random values
const tokenUpdateData = {
actor,
placement,
tokenUpdates: {},
actorUpdates: await this.getChanges(actor, profile)
};

/**
* A hook event that fires before a specific token is summoned. After placement has been determined but before
* the final token data is constructed.
* @function dnd5e.preSummonToken
* @memberof hookEvents
* @param {Item5e} item The item that is performing the summoning.
* @param {SummonsProfile} profile Profile used for summoning.
* @param {TokenUpdateData} config Configuration for creating a modified token.
* @returns {boolean} Explicitly return `false` to prevent this token from being summoned.
*/
if ( Hooks.call("dnd5e.preSummonToken", this.item, profile, tokenUpdateData) === false ) continue;

// Create a token document and apply updates
const tokenData = await this.getTokenData(tokenUpdateData);

/**
* A hook event that fires after token creation data is prepared, but before summoning occurs.
* @function dnd5e.summonToken
* @memberof hookEvents
* @param {Item5e} item The item that is performing the summoning.
* @param {SummonsProfile} profile Profile used for summoning.
* @param {object} tokenData Data for creating a token.
*/
Hooks.callAll("dnd5e.summonToken", this.item, profile, tokenData);

tokensData.push(tokenData);
}
} finally {
if ( minimized ) this.item.parent?.sheet.maximize();
}

const createdTokens = await canvas.scene.createEmbeddedDocuments("Token", tokensData);

/**
* A hook event that fires when summoning is complete.
* @function dnd5e.postSummon
* @memberof hookEvents
* @param {Item5e} item The item that is performing the summoning.
* @param {SummonsProfile} profile Profile used for summoning.
* @param {Token5e[]} tokens Tokens that have been created.
*/
Hooks.callAll("dnd5e.postSummon", this.item, profile, createdTokens);
}

/* -------------------------------------------- */

/**
* Prepare the updates to apply to the summoned actor.
* @param {Actor5e} actor Actor that will be modified.
* @param {SummonsProfile} profile Summoning profile used to summon the actor.
* @returns {object} Changes that will be applied to the actor & its items.
*/
async getChanges(actor, profile) {
const updates = { actor: {}, effects: [], items: [] };
const rollData = this.item.getRollData();
const prof = rollData.attributes?.prof ?? 0;

// Match proficiency
if ( this.match.proficiency ) {
const proficiencyEffect = new ActiveEffect({
changes: [{
key: "system.attributes.prof",
mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
value: prof
}],
disabled: false,
icon: "icons/skills/targeting/crosshair-bars-yellow.webp",
name: game.i18n.localize("DND5E.Summoning.Match.Proficiency.Label")
});
updates.effects.push(proficiencyEffect.toObject());
}

// Add bonus to AC
if ( this.bonuses.ac ) {
const acBonus = new Roll(this.bonuses.ac, rollData);
await acBonus.evaluate();
if ( acBonus.total ) {
if ( actor.system.attributes.ac.calc === "flat" ) {
updates["system.attributes.ac.flat"] = (actor.system.attributes.ac.flat ?? 0) + acBonus.total;
} else {
updates.effects.push((new ActiveEffect({
changes: [{
key: "system.attributes.ac.bonus",
mode: CONST.ACTIVE_EFFECT_MODES.ADD,
value: acBonus.total
}],
disabled: false,
icon: "icons/magic/defensive/shield-barrier-blue.webp",
name: game.i18n.localize("DND5E.Summoning.ArmorClass.Label")
})).toObject());
}
}
}

// Add bonus to HP
if ( this.bonuses.hp ) {
const hpBonus = new Roll(this.bonuses.hp, rollData);
await hpBonus.evaluate();
if ( hpBonus.total ) {
if ( (actor.type === "pc") && !actor._source.system.attributes.hp.max ) {
updates.effects.push((new ActiveEffect({
changes: [{
key: "system.attributes.hp.bonuses.overall",
mode: CONST.ACTIVE_EFFECT_MODES.ADD,
value: hpBonus.total
}],
disabled: false,
icon: "icons/magic/life/heart-glowing-red.webp",
name: game.i18n.localize("DND5E.Summoning.HitPoints.Label")
})).toObject());
} else {
updates["system.attributes.hp.max"] = actor.system.attributes.hp.max + hpBonus.total;
}
updates["system.attributes.hp.value"] = actor.system.attributes.hp.value + hpBonus.total;
}
}

if ( !this.match.attacks && !this.match.saves ) return updates;

for ( const item of actor.items ) {
const itemUpdates = {};

// Match attacks
if ( this.match.attacks && item.hasAttack ) {
const ability = this.item.abilityMod ?? rollData.attributes?.spellcasting;
const typeMapping = { mwak: "msak", rwak: "rsak" };
const parts = [
rollData.abilities?.[ability]?.mod,
prof,
rollData.bonuses?.[typeMapping[item.system.actionType] ?? item.system.actionType]?.attack
].filter(p => p);
itemUpdates["system.attack.bonus"] = parts.join(" + ");
itemUpdates["system.attack.flat"] = true;
}

// Match saves
if ( this.match.saves && item.hasSave ) {
itemUpdates["system.save.dc"] = rollData.item.save.dc ?? rollData.attributes.spelldc;
itemUpdates["system.save.scaling"] = "flat";
}

if ( !foundry.utils.isEmpty(itemUpdates) ) {
itemUpdates._id = item.id;
updates.items.push(itemUpdates);
}
}

return updates;
}

/* -------------------------------------------- */

/**
* Data for token placement on the scene.
*
* @typedef {object} PlacementData
* @property {number} x
* @property {number} y
* @property {number} rotation
*/

/**
* Determine where the summons should be placed on the scene.
* @param {TokenDocument5e} token Token to be placed.
* @param {SummonsProfile} profile Profile used for summoning.
* @returns {PlacementData[]}
*/
async getPlacement(token, profile) {
// TODO: Query use for placement
return [{x: 1000, y: 1000, rotation: 0}];
}

/* -------------------------------------------- */

/**
* Configuration for creating a modified token.
*
* @typedef {object} TokenUpdateData
* @property {Actor5e} actor Original actor from which the token will be created.
* @property {PlacementData} placement Information on the location to summon the token.
* @property {object} tokenUpdates Additional updates that will be applied to token data.
* @property {object} actorUpdates Updates that will be applied to actor delta.
*/

/**
* Create token data ready to be summoned.
* @param {config} TokenUpdateData Configuration for creating a modified token.
* @returns {object}
*/
async getTokenData({ actor, placement, tokenUpdates, actorUpdates }) {
const tokenDocument = await actor.getTokenDocument(foundry.utils.mergeObject(placement, tokenUpdates));
tokenDocument.delta.updateSource(actorUpdates);
return tokenDocument.toObject();
}
}
Loading

0 comments on commit 113581c

Please sign in to comment.