diff --git a/daggerheart.d.ts b/daggerheart.d.ts index 3b753baf..ab754b17 100644 --- a/daggerheart.d.ts +++ b/daggerheart.d.ts @@ -1,4 +1,3 @@ -import './module/_types'; import '@client/global.mjs'; import Canvas from '@client/canvas/board.mjs'; diff --git a/daggerheart.mjs b/daggerheart.mjs index 58600ad4..eedf278f 100644 --- a/daggerheart.mjs +++ b/daggerheart.mjs @@ -18,6 +18,7 @@ globalThis.SYSTEM = SYSTEM; Hooks.once('init', () => { CONFIG.daggerheart = SYSTEM; + game.system.api = { applications, models, diff --git a/module/_types.d.ts b/module/_types.d.ts deleted file mode 100644 index 7114f510..00000000 --- a/module/_types.d.ts +++ /dev/null @@ -1 +0,0 @@ -import './data/pseudo-documents/_types'; diff --git a/module/config/pseudoConfig.mjs b/module/config/pseudoConfig.mjs new file mode 100644 index 00000000..2ada8e4f --- /dev/null +++ b/module/config/pseudoConfig.mjs @@ -0,0 +1,7 @@ +import { pseudoDocuments } from "../data/_module.mjs"; + +export default { + feature: { + weapon: pseudoDocuments.feature.WeaponFeature, + } +}; \ No newline at end of file diff --git a/module/config/system.mjs b/module/config/system.mjs index fd198443..be2fc1aa 100644 --- a/module/config/system.mjs +++ b/module/config/system.mjs @@ -5,6 +5,7 @@ import * as ITEM from './itemConfig.mjs'; import * as SETTINGS from './settingsConfig.mjs'; import * as EFFECTS from './effectConfig.mjs'; import * as ACTIONS from './actionConfig.mjs'; +import pseudoDocuments from "./pseudoConfig.mjs"; export const SYSTEM_ID = 'daggerheart'; @@ -16,5 +17,6 @@ export const SYSTEM = { ITEM, SETTINGS, EFFECTS, - ACTIONS + ACTIONS, + pseudoDocuments }; diff --git a/module/data/_module.mjs b/module/data/_module.mjs index 57e977a2..a38b24fc 100644 --- a/module/data/_module.mjs +++ b/module/data/_module.mjs @@ -8,5 +8,5 @@ export { default as DhpEnvironment } from './environment.mjs'; export * as items from './item/_module.mjs'; export * as messages from './chat-message/_modules.mjs'; -export * as fields from "./fields/_module.mjs"; -export * as PseudoDocuments from "./pseudo-documents/_module.mjs"; +export * as fields from './fields/_module.mjs'; +export * as pseudoDocuments from './pseudo-documents/_module.mjs'; diff --git a/module/data/fields/_module.mjs b/module/data/fields/_module.mjs index dc57c779..3a573a0b 100644 --- a/module/data/fields/_module.mjs +++ b/module/data/fields/_module.mjs @@ -1,3 +1,3 @@ -export { default as FormulaField } from "./formulaField.mjs"; -export { default as ForeignDocumentUUIDField } from "./foreignDocumentUUIDField.mjs"; -export { default as PseudoDocumentsField } from "./pseudoDocumentsField.mjs"; \ No newline at end of file +export { default as FormulaField } from './formulaField.mjs'; +export { default as ForeignDocumentUUIDField } from './foreignDocumentUUIDField.mjs'; +export { default as PseudoDocumentsField } from './pseudoDocumentsField.mjs'; diff --git a/module/data/fields/formulaField.mjs b/module/data/fields/formulaField.mjs index 82717740..68c26efc 100644 --- a/module/data/fields/formulaField.mjs +++ b/module/data/fields/formulaField.mjs @@ -16,78 +16,78 @@ * Special case StringField which represents a formula. */ export default class FormulaField extends foundry.data.fields.StringField { + /** + * @param {FormulaFieldOptions} [options] - Options which configure the behavior of the field + * @param {foundry.data.types.DataFieldContext} [context] - Additional context which describes the field + */ + constructor(options, context) { + super(options, context); + } - /** - * @param {FormulaFieldOptions} [options] - Options which configure the behavior of the field - * @param {foundry.data.types.DataFieldContext} [context] - Additional context which describes the field - */ - constructor(options, context) { - super(options, context); - } + /** @inheritDoc */ + static get _defaults() { + return foundry.utils.mergeObject(super._defaults, { + deterministic: false + }); + } - /** @inheritDoc */ - static get _defaults() { - return foundry.utils.mergeObject(super._defaults, { - deterministic: false - }); - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** @inheritDoc */ + _validateType(value) { + const roll = new Roll(value.replace(/@([a-z.0-9_-]+)/gi, '1')); + roll.evaluateSync({ strict: false }); + if (this.options.deterministic && !roll.isDeterministic) + throw new Error(`must not contain dice terms: ${value}`); + super._validateType(value); + } - /** @inheritDoc */ - _validateType(value) { - const roll = new Roll(value.replace(/@([a-z.0-9_-]+)/gi, "1")); - roll.evaluateSync({ strict: false }); - if (this.options.deterministic && !roll.isDeterministic) throw new Error(`must not contain dice terms: ${value}`); - super._validateType(value); - } + /* -------------------------------------------- */ + /* Active Effect Integration */ + /* -------------------------------------------- */ - /* -------------------------------------------- */ - /* Active Effect Integration */ - /* -------------------------------------------- */ + /** @override */ + _castChangeDelta(delta) { + return this._cast(delta).trim(); + } - /** @override */ - _castChangeDelta(delta) { - return this._cast(delta).trim(); - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** @override */ + _applyChangeAdd(value, delta, model, change) { + if (!value) return delta; + const operator = delta.startsWith('-') ? '-' : '+'; + delta = delta.replace(/^[+-]/, '').trim(); + return `${value} ${operator} ${delta}`; + } - /** @override */ - _applyChangeAdd(value, delta, model, change) { - if (!value) return delta; - const operator = delta.startsWith("-") ? "-" : "+"; - delta = delta.replace(/^[+-]/, "").trim(); - return `${value} ${operator} ${delta}`; - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** @override */ + _applyChangeMultiply(value, delta, model, change) { + if (!value) return delta; + const terms = new Roll(value).terms; + if (terms.length > 1) return `(${value}) * ${delta}`; + return `${value} * ${delta}`; + } - /** @override */ - _applyChangeMultiply(value, delta, model, change) { - if (!value) return delta; - const terms = new Roll(value).terms; - if (terms.length > 1) return `(${value}) * ${delta}`; - return `${value} * ${delta}`; - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** @override */ + _applyChangeUpgrade(value, delta, model, change) { + if (!value) return delta; + const terms = new Roll(value).terms; + if (terms.length === 1 && terms[0].fn === 'max') return value.replace(/\)$/, `, ${delta})`); + return `max(${value}, ${delta})`; + } - /** @override */ - _applyChangeUpgrade(value, delta, model, change) { - if (!value) return delta; - const terms = new Roll(value).terms; - if ((terms.length === 1) && (terms[0].fn === "max")) return value.replace(/\)$/, `, ${delta})`); - return `max(${value}, ${delta})`; - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ - - /** @override */ - _applyChangeDowngrade(value, delta, model, change) { - if (!value) return delta; - const terms = new Roll(value).terms; - if ((terms.length === 1) && (terms[0].fn === "min")) return value.replace(/\)$/, `, ${delta})`); - return `min(${value}, ${delta})`; - } -} \ No newline at end of file + /** @override */ + _applyChangeDowngrade(value, delta, model, change) { + if (!value) return delta; + const terms = new Roll(value).terms; + if (terms.length === 1 && terms[0].fn === 'min') return value.replace(/\)$/, `, ${delta})`); + return `min(${value}, ${delta})`; + } +} diff --git a/module/data/fields/pseudoDocumentsField.mjs b/module/data/fields/pseudoDocumentsField.mjs index 718bd42d..70dfba8a 100644 --- a/module/data/fields/pseudoDocumentsField.mjs +++ b/module/data/fields/pseudoDocumentsField.mjs @@ -1,16 +1,44 @@ -import { BasePseudoDocument } from "../pseudo-documents/_types"; -export default class PseudoDocumentsField extends foundry.data.fields.TypedObjectField { +import PseudoDocument from '../pseudo-documents/base/pseudoDocument.mjs'; + +const { TypedObjectField, TypedSchemaField } = foundry.data.fields; + +/** + * @typedef _PseudoDocumentsFieldOptions + * @property {Number} [max] - The maximum amount of elements (default: `Infinity`) + * @property {String[]} [validTypes] - Allowed pseudo-documents types (default: `[]`) + * @property {Function} [validateKey] - callback for validate keys of the object; + + * @typedef {foundry.data.types.DataFieldOptions & _PseudoDocumentsFieldOptions} PseudoDocumentsFieldOptions + */ +export default class PseudoDocumentsField extends TypedObjectField { + /** + * @param {PseudoDocument} model - The PseudoDocument of each entry in this collection. + * @param {PseudoDocumentsFieldOptions} [options] - Options which configure the behavior of the field + * @param {foundry.data.types.DataFieldContext} [context] - Additional context which describes the field + */ constructor(model, options = {}, context = {}) { - options.validateKey ||= ((key) => foundry.data.validators.isValidId(key)); - if (!(model instanceof BasePseudoDocument)) throw new Error("The model must be a PseudoDocument"); - const field = new foundry.data.fields.EmbeddedDataField(model); + options.validateKey ||= key => foundry.data.validators.isValidId(key); + if (!foundry.utils.isSubclass(model, PseudoDocument)) throw new Error('The model must be a PseudoDocument'); + + const allTypes = foundry.utils.duplicate(model.TYPES); + options.validTypes ??= Object.keys(allTypes); + const filteredTypes = {}; + for (const typeName of options.validTypes) { + if (typeName in allTypes) { + filteredTypes[typeName] = allTypes[typeName]; + } else { + console.warn(`Document type "${typeName}" is not found in model.TYPES`); + } + } + const field = new TypedSchemaField(filteredTypes); super(field, options, context); } /** @inheritdoc */ static get _defaults() { return Object.assign(super._defaults, { - max: Infinity + max: Infinity, + validTypes: [] }); } @@ -19,4 +47,12 @@ export default class PseudoDocumentsField extends foundry.data.fields.TypedObjec if (Object.keys(value).length > this.max) throw new Error(`cannot have more than ${this.max} elements`); return super._validateType(value, options); } -} \ No newline at end of file + + /** @override */ + initialize(value, model, options = {}) { + if (!value) return; + value = super.initialize(value, model, options); + const collection = new foundry.utils.Collection(Object.values(value).map(d => [d._id, d])); + return collection; + } +} diff --git a/module/data/item/weapon.mjs b/module/data/item/weapon.mjs index b3c82e52..cd7a25b6 100644 --- a/module/data/item/weapon.mjs +++ b/module/data/item/weapon.mjs @@ -1,14 +1,19 @@ -import BaseDataItem from "./base.mjs"; -import FormulaField from "../fields/formulaField.mjs"; +import BaseDataItem from './base.mjs'; +import FormulaField from '../fields/formulaField.mjs'; +import PseudoDocumentsField from '../fields/pseudoDocumentsField.mjs'; +import BaseFeatureData from '../pseudo-documents/feature/baseFeatureData.mjs'; export default class DHWeapon extends BaseDataItem { /** @inheritDoc */ static get metadata() { return foundry.utils.mergeObject(super.metadata, { - label: "TYPES.Item.weapon", - type: "weapon", + label: 'TYPES.Item.weapon', + type: 'weapon', hasDescription: true, isQuantifiable: true, + embedded: { + feature: 'featureTest' + } }); } @@ -35,6 +40,12 @@ export default class DHWeapon extends BaseDataItem { }), feature: new fields.StringField({ choices: SYSTEM.ITEM.weaponFeatures, blank: true }), + featureTest: new PseudoDocumentsField(BaseFeatureData, { + required: true, + nullable: true, + max: 1, + validTypes: ['weapon'] + }) }; } } diff --git a/module/data/pseudo-documents/_module.mjs b/module/data/pseudo-documents/_module.mjs index cb9376d2..6f50a137 100644 --- a/module/data/pseudo-documents/_module.mjs +++ b/module/data/pseudo-documents/_module.mjs @@ -1,7 +1,2 @@ -import BasePseudoDocument from "./base.mjs"; -import PseudoDocument from "./pseudoDocument.mjs"; - -export { - BasePseudoDocument, - PseudoDocument -} \ No newline at end of file +export { default as base } from './base/pseudoDocument.mjs'; +export * as feature from './feature/_module.mjs'; diff --git a/module/data/pseudo-documents/_types.d.ts b/module/data/pseudo-documents/_types.d.ts deleted file mode 100644 index e4b4db77..00000000 --- a/module/data/pseudo-documents/_types.d.ts +++ /dev/null @@ -1,36 +0,0 @@ -import ApplicationV2 from '@client/applications/api/application.mjs'; -import DataModel from '@common/abstract/data.mjs'; - -export type PseudoDocumentMetadata = { - /* The document name of this pseudo-document. */ - name: string; - /** The localization string for this pseudo-document */ - label: string; - /** The font-awesome icon for this pseudo-document type */ - icon: string; - /* Record of document names of pseudo-documents and the path to the collection. */ - embedded: Record; - /* The class used to render this pseudo-document. */ - sheetClass?: ApplicationV2; - /* The default image used for newly created documents. */ - defaultArtwork: string; -}; - -/** - * Base data model for pseudo-documents. - */ -declare class BasePseudoDocument extends DataModel { - /** The _id which identifies this pseudo-document */ - _id: string; - /** The name of this pseudo-document */ - name: string; - /** An image file path which provides the artwork for this pseudo-document */ - img: string; - /** An HTML text description for this pseudo-document */ - description: string; -} - -/** - * Data model for pseudo-documents. - */ -declare class PseudoDocument extends BasePseudoDocument {} diff --git a/module/data/pseudo-documents/base.mjs b/module/data/pseudo-documents/base.mjs deleted file mode 100644 index e3b8c642..00000000 --- a/module/data/pseudo-documents/base.mjs +++ /dev/null @@ -1,206 +0,0 @@ -/** @import { PseudoDocumentMetadata, BasePseudoDocument } from "./_types" */ - -/**@implements {BasePseudoDocument}*/ -export default class BasePseudoDocument extends foundry.abstract.DataModel { - /** - * Pseudo-document metadata. - * @type {PseudoDocumentMetadata} - */ - static get metadata() { - return { - name: null, - label: "", - icon: "", - embedded: {}, - sheetClass: null, - defaultArtwork: foundry.documents.Item.DEFAULT_ICON, - }; - } - - /** @override */ - static LOCALIZATION_PREFIXES = ["DOCUMENT"]; - - /** @inheritdoc */ - static defineSchema() { - const { fields } = foundry.data; - - return { - _id: new fields.DocumentIdField({ initial: () => foundry.utils.randomID() }), - name: new fields.StringField({ required: true, blank: false, textSearch: true }), - img: new fields.FilePathField({ categories: ["IMAGE"], initial: this.metadata.defaultArtwork }), - description: new fields.HTMLField({ textSearch: true }), - }; - } - - /* -------------------------------------------- */ - /* Instance Properties */ - /* -------------------------------------------- */ - - /** - * The id of this pseudo-document. - * @type {string} - */ - get id() { - return this._id; - } - - /* -------------------------------------------- */ - - /** - * The uuid of this document. - * @type {string} - */ - get uuid() { - let parent = this.parent; - while (!(parent instanceof BasePseudoDocument) && !(parent instanceof foundry.abstract.Document)) parent = parent.parent; - return [parent.uuid, this.constructor.metadata.name, this.id].join("."); - } - - /* -------------------------------------------- */ - - /** - * The parent document of this pseudo-document. - * @type {Document} - */ - get document() { - let parent = this; - while (!(parent instanceof foundry.abstract.Document)) parent = parent.parent; - return parent; - } - - /* -------------------------------------------- */ - - /** - * Item to which this PseudoDocument belongs, if applicable. - * @type {foundry.documents.Item|null} - */ - get item() { - return this.parent?.parent instanceof Item ? this.parent.parent : null; - } - - /* -------------------------------------------- */ - - /** - * Actor to which this PseudoDocument's item belongs, if the item is embedded. - * @type {foundry.documents.Actor|null} - */ - get actor() { - return this.item?.parent ?? null; - } - - /* -------------------------------------------- */ - - /** - * The property path to this pseudo document relative to its parent document. - * @type {string} - */ - get fieldPath() { - const fp = this.schema.fieldPath; - let path = fp.slice(0, fp.lastIndexOf("element") - 1); - - if (this.parent instanceof BasePseudoDocument) { - path = [this.parent.fieldPath, this.parent.id, path].join("."); - } - - return path; - } - - /* -------------------------------------------- */ - /* Embedded Document Methods */ - /* -------------------------------------------- */ - - /** - * Retrieve an embedded pseudo-document. - * @param {string} embeddedName The document name of the embedded pseudo-document. - * @param {string} id The id of the embedded pseudo-document. - * @param {object} [options] Retrieval options. - * @param {boolean} [options.invalid] Retrieve an invalid pseudo-document? - * @param {boolean} [options.strinct] Throw an error if the embedded pseudo-document does not exist? - * @returns {PseudoDocument|null} - */ - getEmbeddedDocument(embeddedName, id, { invalid = false, strict = false } = {}) { - const embeds = this.constructor.metadata.embedded ?? {}; - if (embeddedName in embeds) { - const path = `${embeds[embeddedName]}.${id}`; - return foundry.utils.getProperty(this, path) ?? null; - } - return null; - } - - - /* -------------------------------------------- */ - /* CRUD Operations */ - /* -------------------------------------------- */ - - /** - * Does this pseudo-document exist in the document's source? - * @type {boolean} - */ - get isSource() { - const source = foundry.utils.getProperty(this.document._source, this.fieldPath); - if (foundry.utils.getType(source) !== "Object") { - throw new Error("Source is not an object!"); - } - return this.id in source; - } - - /** - * Create a new instance of this pseudo-document. - * @param {object} [data] The data used for the creation. - * @param {object} operation The context of the update operation. - * @param {foundry.abstract.Document} operation.parent The parent of this document. - * @returns {Promise} A promise that resolves to the updated document. - */ - static async create(data = {}, { parent, ...operation } = {}) { - if (!parent) { - throw new Error("A parent document must be specified for the creation of a pseudo-document!"); - } - const id = operation.keepId && foundry.data.validators.isValidId(data._id) ? data._id : foundry.utils.randomID(); - - const fieldPath = parent.system.constructor.metadata.embedded?.[this.metadata.name]; - if (!fieldPath) { - throw new Error(`A ${parent.documentName} of type '${parent.type}' does not support ${this.metadata.name}!`); - } - - const update = { [`system.${fieldPath}.${id}`]: { ...data, _id: id } }; - return parent.update(update, operation); - } - - - /** - * Delete this pseudo-document. - * @param {object} [operation] The context of the operation. - * @returns {Promise} A promise that resolves to the updated document. - */ - async delete(operation = {}) { - if (!this.isSource) throw new Error("You cannot delete a non-source pseudo-document!"); - const update = { [`${this.fieldPath}.-=${this.id}`]: null }; - return this.document.update(update, operation); - } - - /** - * Duplicate this pseudo-document. - * @returns {Promise} A promise that resolves to the updated document. - */ - async duplicate() { - if (!this.isSource) throw new Error("You cannot duplicate a non-source pseudo-document!"); - const docData = foundry.utils.mergeObject(this.toObject(), { - name: game.i18n.format("DOCUMENT.CopyOf", { name: this.name }), - }); - return this.constructor.create(docData, { parent: this.document }); - } - - /** - * Update this pseudo-document. - * @param {object} [change] The change to perform. - * @param {object} [operation] The context of the operation. - * @returns {Promise} A promise that resolves to the updated document. - */ - async update(change = {}, operation = {}) { - if (!this.isSource) throw new Error("You cannot update a non-source pseudo-document!"); - const path = [this.fieldPath, this.id].join("."); - const update = { [path]: change }; - return this.document.update(update, operation); - } - -} \ No newline at end of file diff --git a/module/data/pseudo-documents/base/base.mjs b/module/data/pseudo-documents/base/base.mjs new file mode 100644 index 00000000..b2ed2cd2 --- /dev/null +++ b/module/data/pseudo-documents/base/base.mjs @@ -0,0 +1,213 @@ +/** + * @typedef {object} PseudoDocumentMetadata + * @property {string} name - The document name of this pseudo-document + * @property {Record} embedded - Record of document names and their collection paths + * @property {typeof foundry.applications.api.ApplicationV2} [sheetClass] - The class used to render this pseudo-document + * @property {string} defaultArtwork - The default image used for newly created documents + */ + +/** + * @class Base class for pseudo-documents + * @extends {foundry.abstract.DataModel} + */ +export default class BasePseudoDocument extends foundry.abstract.DataModel { + /** + * Pseudo-document metadata. + * @returns {PseudoDocumentMetadata} + */ + static get metadata() { + return { + name: '', + embedded: {}, + defaultArtwork: foundry.documents.Item.DEFAULT_ICON, + sheetClass: null + }; + } + + /** @override */ + static LOCALIZATION_PREFIXES = ['DOCUMENT']; + + /** @inheritdoc */ + static defineSchema() { + const { fields } = foundry.data; + + return { + _id: new fields.DocumentIdField({ initial: () => foundry.utils.randomID() }), + name: new fields.StringField({ required: true, blank: false, textSearch: true }), + img: new fields.FilePathField({ categories: ['IMAGE'], initial: this.metadata.defaultArtwork }), + description: new fields.HTMLField({ textSearch: true }) + }; + } + + /* -------------------------------------------- */ + /* Instance Properties */ + /* -------------------------------------------- */ + + /** + * The id of this pseudo-document. + * @type {string} + */ + get id() { + return this._id; + } + + /* -------------------------------------------- */ + + /** + * The uuid of this document. + * @type {string} + */ + get uuid() { + let parent = this.parent; + while (!(parent instanceof BasePseudoDocument) && !(parent instanceof foundry.abstract.Document)) + parent = parent.parent; + return [parent.uuid, this.constructor.metadata.name, this.id].join('.'); + } + + /* -------------------------------------------- */ + + /** + * The parent document of this pseudo-document. + * @type {Document} + */ + get document() { + let parent = this; + while (!(parent instanceof foundry.abstract.Document)) parent = parent.parent; + return parent; + } + + /* -------------------------------------------- */ + + /** + * Item to which this PseudoDocument belongs, if applicable. + * @type {foundry.documents.Item|null} + */ + get item() { + return this.parent?.parent instanceof Item ? this.parent.parent : null; + } + + /* -------------------------------------------- */ + + /** + * Actor to which this PseudoDocument's item belongs, if the item is embedded. + * @type {foundry.documents.Actor|null} + */ + get actor() { + return this.item?.parent ?? null; + } + + /* -------------------------------------------- */ + + /** + * The property path to this pseudo document relative to its parent document. + * @type {string} + */ + get fieldPath() { + const fp = this.schema.fieldPath; + let path = fp.slice(0, fp.lastIndexOf('element') - 1); + + if (this.parent instanceof BasePseudoDocument) { + path = [this.parent.fieldPath, this.parent.id, path].join('.'); + } + + return path; + } + + /* -------------------------------------------- */ + /* Embedded Document Methods */ + /* -------------------------------------------- */ + + /** + * Retrieve an embedded pseudo-document. + * @param {string} embeddedName The document name of the embedded pseudo-document. + * @param {string} id The id of the embedded pseudo-document. + * @param {object} [options] Retrieval options. + * @param {boolean} [options.invalid] Retrieve an invalid pseudo-document? + * @param {boolean} [options.strinct] Throw an error if the embedded pseudo-document does not exist? + * @returns {PseudoDocument|null} + */ + getEmbeddedDocument(embeddedName, id, { invalid = false, strict = false } = {}) { + const embeds = this.constructor.metadata.embedded ?? {}; + if (embeddedName in embeds) { + return foundry.utils.getProperty(this, embeds[embeddedName]).get(id) ?? null; + } + return null; + } + + /* -------------------------------------------- */ + /* CRUD Operations */ + /* -------------------------------------------- */ + + /** + * Does this pseudo-document exist in the document's source? + * @type {boolean} + */ + get isSource() { + const source = foundry.utils.getProperty(this.document._source, this.fieldPath); + if (foundry.utils.getType(source) !== 'Object') { + throw new Error('Source is not an object!'); + } + return this.id in source; + } + + /** + * Create a new instance of this pseudo-document. + * @param {object} [data] The data used for the creation. + * @param {object} operation The context of the update operation. + * @param {foundry.abstract.Document} operation.parent The parent of this document. + * @returns {Promise} A promise that resolves to the updated document. + */ + static async create(data = {}, { parent, ...operation } = {}) { + if (!parent) { + throw new Error('A parent document must be specified for the creation of a pseudo-document!'); + } + const id = + operation.keepId && foundry.data.validators.isValidId(data._id) ? data._id : foundry.utils.randomID(); + + const fieldPath = parent.system.constructor.metadata.embedded?.[this.metadata.name]; + if (!fieldPath) { + throw new Error( + `A ${parent.documentName} of type '${parent.type}' does not support ${this.metadata.name}!` + ); + } + + const update = { [`system.${fieldPath}.${id}`]: { ...data, _id: id } }; + return parent.update(update, operation); + } + + /** + * Delete this pseudo-document. + * @param {object} [operation] The context of the operation. + * @returns {Promise} A promise that resolves to the updated document. + */ + async delete(operation = {}) { + if (!this.isSource) throw new Error('You cannot delete a non-source pseudo-document!'); + const update = { [`${this.fieldPath}.-=${this.id}`]: null }; + return this.document.update(update, operation); + } + + /** + * Duplicate this pseudo-document. + * @returns {Promise} A promise that resolves to the updated document. + */ + async duplicate() { + if (!this.isSource) throw new Error('You cannot duplicate a non-source pseudo-document!'); + const docData = foundry.utils.mergeObject(this.toObject(), { + name: game.i18n.format('DOCUMENT.CopyOf', { name: this.name }) + }); + return this.constructor.create(docData, { parent: this.document }); + } + + /** + * Update this pseudo-document. + * @param {object} [change] The change to perform. + * @param {object} [operation] The context of the operation. + * @returns {Promise} A promise that resolves to the updated document. + */ + async update(change = {}, operation = {}) { + if (!this.isSource) throw new Error('You cannot update a non-source pseudo-document!'); + const path = [this.fieldPath, this.id].join('.'); + const update = { [path]: change }; + return this.document.update(update, operation); + } +} diff --git a/module/data/pseudo-documents/base/pseudoDocument.mjs b/module/data/pseudo-documents/base/pseudoDocument.mjs new file mode 100644 index 00000000..b5c6e8ed --- /dev/null +++ b/module/data/pseudo-documents/base/pseudoDocument.mjs @@ -0,0 +1,48 @@ +import BasePseudoDocument from './base.mjs'; +import SheetManagementMixin from './sheetManagementMixin.mjs'; + +/** @extends BasePseudoDocument */ +export default class PseudoDocument extends SheetManagementMixin(BasePseudoDocument) { + static get TYPES() { + return (this._TYPES ??= Object.freeze(CONFIG.daggerheart.pseudoDocuments[this.metadata.name])); + } + + static _TYPES; + + /* -------------------------------------------- */ + + /** + * The type of this shape. + * @type {string} + */ + static TYPE = ''; + + /* -------------------------------------------- */ + + /** @override */ + static defineSchema() { + const { fields } = foundry.data; + + return Object.assign(super.defineSchema(), { + type: new fields.StringField({ + required: true, + blank: false, + initial: this.TYPE, + validate: value => value === this.TYPE, + validationError: `must be equal to "${this.TYPE}"` + }) + }); + } + + /** @inheritdoc */ + static async create(data = {}, { parent, ...operation } = {}) { + data = foundry.utils.deepClone(data); + if (!data.type) data.type = Object.keys(this.TYPES)[0]; + if (!(data.type in this.TYPES)) { + throw new Error( + `The '${data.type}' type is not a valid type for a '${this.metadata.documentName}' pseudo-document!` + ); + } + return super.create(data, { parent, ...operation }); + } +} diff --git a/module/data/pseudo-documents/base/sheetManagementMixin.mjs b/module/data/pseudo-documents/base/sheetManagementMixin.mjs new file mode 100644 index 00000000..14256517 --- /dev/null +++ b/module/data/pseudo-documents/base/sheetManagementMixin.mjs @@ -0,0 +1,120 @@ +import BasePseudoDocument from './base.mjs'; +const { ApplicationV2 } = foundry.applications.api; + +/** + * A mixin that adds sheet management capabilities to pseudo-documents + * @template {typeof BasePseudoDocument} T + * @param {T} Base + * @returns {T & typeof PseudoDocumentWithSheets} + */ +export default function SheetManagementMixin(Base) { + class PseudoDocumentWithSheets extends Base { + /** + * Reference to the sheet of this pseudo-document. + * @type {ApplicationV2|null} + */ + get sheet() { + if (this._sheet) return this._sheet; + const cls = this.constructor.metadata.sheetClass ?? ApplicationV2; + if (!ApplicationV2.isPrototypeOf(cls)) { + return void ui.notifications.error( + 'Daggerheart | Error on PseudoDocument | sheetClass must be ApplicationV2' + ); + } + + const sheet = new cls({ document: this }); + this._sheet = sheet; + } + + /* -------------------------------------------- */ + /* Static Properties */ + /* -------------------------------------------- */ + + /** + * Set of apps what should be re-render. + * @type {Set} + * @internal + */ + _apps = new Set(); + + /* -------------------------------------------- */ + + /** + * Existing sheets of a specific type for a specific document. + * @type {ApplicationV2 | null} + */ + _sheet = null; + + /* -------------------------------------------- */ + /* Display Methods */ + /* -------------------------------------------- */ + + /** + * Render all the Application instances which are connected to this PseudoDocument. + * @param {ApplicationRenderOptions} [options] Rendering options. + */ + render(options) { + for (const app of this._apps ?? []) { + app.render({ window: { title: app.title }, ...options }); + } + } + + /* -------------------------------------------- */ + + /** + * Register an application to respond to updates to a certain document. + * @param {ApplicationV2} app Application to update. + * @internal + */ + _registerApp(app) { + this._apps.add(app); + } + + /* -------------------------------------------- */ + + /** + * Remove an application from the render registry. + * @param {ApplicationV2} app Application to stop watching. + */ + _unregisterApp(app) { + this._apps.delete(app); + } + + /* -------------------------------------------- */ + /* Drag and Drop */ + /* -------------------------------------------- */ + + /** + * Serialize salient information for this PseudoDocument when dragging it. + * @returns {object} An object of drag data. + */ + toDragData() { + const dragData = { type: this.documentName, data: this.toObject() }; + if (this.id) dragData.uuid = this.uuid; + return dragData; + } + + /* -------------------------------------------- */ + /* Dialog Methods */ + /* -------------------------------------------- */ + + /** + * Spawn a dialog for creating a new PseudoDocument. + * @param {object} [data] Data to pre-populate the document with. + * @param {object} context + * @param {foundry.documents.Item} context.parent A parent for the document. + * @param {string[]|null} [context.types] A list of types to restrict the choices to, or null for no restriction. + * @returns {Promise} + */ + static async createDialog(data = {}, { parent, types = null, ...options } = {}) {} + + /** + * Present a Dialog form to confirm deletion of this PseudoDocument. + * @param {object} [options] Positioning and sizing options for the resulting dialog. + * @returns {Promise} A Promise which resolves to the deleted PseudoDocument. + */ + async deleteDialog(options = {}) {} + } + + return PseudoDocumentWithSheets; +} diff --git a/module/data/pseudo-documents/feature/_module.mjs b/module/data/pseudo-documents/feature/_module.mjs new file mode 100644 index 00000000..794f5d27 --- /dev/null +++ b/module/data/pseudo-documents/feature/_module.mjs @@ -0,0 +1,2 @@ +export { default as BaseFeatureData } from './baseFeatureData.mjs'; +export { default as WeaponFeature } from './weaponFeature.mjs'; diff --git a/module/data/pseudo-documents/feature/baseFeatureData.mjs b/module/data/pseudo-documents/feature/baseFeatureData.mjs new file mode 100644 index 00000000..412fa128 --- /dev/null +++ b/module/data/pseudo-documents/feature/baseFeatureData.mjs @@ -0,0 +1,25 @@ +import PseudoDocument from '../base/pseudoDocument.mjs'; + +export default class BaseFeatureData extends PseudoDocument { + /**@inheritdoc */ + static get metadata() { + return foundry.utils.mergeObject( + super.metadata, + { + name: 'feature', + label: 'DAGGERHEART.Feature.Label', + embedded: {}, + sheetClass: null //TODO: define feature-sheet + }, + { inplace: false } + ); + } + + static defineSchema() { + const { fields } = foundry.data; + const schema = super.defineSchema(); + return Object.assign(schema, { + subtype: new fields.StringField({ initial: 'test' }) + }); + } +} diff --git a/module/data/pseudo-documents/feature/weaponFeature.mjs b/module/data/pseudo-documents/feature/weaponFeature.mjs new file mode 100644 index 00000000..a734e5da --- /dev/null +++ b/module/data/pseudo-documents/feature/weaponFeature.mjs @@ -0,0 +1,11 @@ +import BaseFeatureData from './baseFeatureData.mjs'; + +export default class WeaponFeature extends BaseFeatureData { + /**@override */ + static TYPE = 'weapon'; + + /**@inheritdoc */ + static get metadata() { + return foundry.utils.mergeObject(super.metadata, {}, { inplace: false }); + } +} diff --git a/module/data/pseudo-documents/pseudoDocument.mjs b/module/data/pseudo-documents/pseudoDocument.mjs deleted file mode 100644 index dc057918..00000000 --- a/module/data/pseudo-documents/pseudoDocument.mjs +++ /dev/null @@ -1,115 +0,0 @@ -/** @import {PseudoDocument} from "./_types" */ -import BasePseudoDocument from "./base.mjs"; - - -const { ApplicationV2 } = foundry.applications.api; - -/** @implements {PseudoDocument}*/ -export default class PseudoDocument extends BasePseudoDocument { - /** - * Reference to the sheet of this pseudo-document. - * @type {ApplicationV2|null} - */ - get sheet() { - if (this._sheet) return this._sheet; - const cls = this.constructor.metadata.sheetClass ?? ApplicationV2; - if (!ApplicationV2.isPrototypeOf(cls)) { - return void ui.notifications.error("Daggerheart | Error on PseudoDocument | sheetClass must be ApplicationV2"); - } - - const sheet = new cls({ document: this }); - this._sheet = sheet; - } - - /* -------------------------------------------- */ - /* Static Properties */ - /* -------------------------------------------- */ - - /** - * Set of apps what should be re-render. - * @type {Set} - * @internal - */ - _apps = new Set(); - - /* -------------------------------------------- */ - - /** - * Existing sheets of a specific type for a specific document. - * @type {ApplicationV2 | null} - */ - _sheet = null; - - /* -------------------------------------------- */ - /* Display Methods */ - /* -------------------------------------------- */ - - /** - * Render all the Application instances which are connected to this PseudoDocument. - * @param {ApplicationRenderOptions} [options] Rendering options. - */ - render(options) { - for (const app of this._apps ?? []) { - app.render({ window: { title: app.title }, ...options }); - } - } - - /* -------------------------------------------- */ - - /** - * Register an application to respond to updates to a certain document. - * @param {ApplicationV2} app Application to update. - * @internal - */ - _registerApp(app) { - this._apps.add(app); - } - - /* -------------------------------------------- */ - - /** - * Remove an application from the render registry. - * @param {ApplicationV2} app Application to stop watching. - */ - _unregisterApp(app) { - this._apps.delete(app) - } - - - /* -------------------------------------------- */ - /* Drag and Drop */ - /* -------------------------------------------- */ - - /** - * Serialize salient information for this PseudoDocument when dragging it. - * @returns {object} An object of drag data. - */ - toDragData() { - const dragData = { type: this.documentName, data: this.toObject() }; - if (this.id) dragData.uuid = this.uuid; - return dragData; - } - - /* -------------------------------------------- */ - /* Dialog Methods */ - /* -------------------------------------------- */ - - /** - * Spawn a dialog for creating a new PseudoDocument. - * @param {object} [data] Data to pre-populate the document with. - * @param {object} context - * @param {foundry.documents.Item} context.parent A parent for the document. - * @param {string[]|null} [context.types] A list of types to restrict the choices to, or null for no restriction. - * @returns {Promise} - */ - static async createDialog(data = {}, { parent, types = null, ...options } = {}) { } - - - /** - * Present a Dialog form to confirm deletion of this PseudoDocument. - * @param {object} [options] Positioning and sizing options for the resulting dialog. - * @returns {Promise} A Promise which resolves to the deleted PseudoDocument. - */ - async deleteDialog(options = {}) { - } -} \ No newline at end of file diff --git a/module/documents/item.mjs b/module/documents/item.mjs index cd0986ae..b8e04797 100644 --- a/module/documents/item.mjs +++ b/module/documents/item.mjs @@ -3,8 +3,8 @@ export default class DhpItem extends Item { getEmbeddedDocument(embeddedName, id, { invalid = false, strict = false } = {}) { const systemEmbeds = this.system.constructor.metadata.embedded ?? {}; if (embeddedName in systemEmbeds) { - const path = `system.${systemEmbeds[embeddedName]}.${id}`; - return foundry.utils.getProperty(this, path) ?? null; + const path = `system.${systemEmbeds[embeddedName]}`; + return foundry.utils.getProperty(this, path).get(id) ?? null; } return super.getEmbeddedDocument(embeddedName, id, { invalid, strict }); }