import Document from "../abstract/document.mjs"; import {mergeObject} from "../utils/helpers.mjs"; import * as CONST from "../constants.mjs"; import * as documents from "./_module.mjs"; import * as fields from "../data/fields.mjs"; import {LightData, TextureData} from "../data/data.mjs"; /** * @typedef {import("./_types.mjs").TokenData} TokenData * @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext */ /** * The Token Document. * Defines the DataSchema and common behaviors for a Token which are shared between both client and server. * @mixes TokenData */ export default class BaseToken extends Document { /** * Construct a Token document using provided data and context. * @param {Partial} data Initial data from which to construct the Token * @param {DocumentConstructionContext} context Construction context options */ constructor(data, context) { super(data, context); } /* -------------------------------------------- */ /* Model Configuration */ /* -------------------------------------------- */ /** @inheritdoc */ static metadata = Object.freeze(mergeObject(super.metadata, { name: "Token", collection: "tokens", label: "DOCUMENT.Token", labelPlural: "DOCUMENT.Tokens", isEmbedded: true, embedded: { ActorDelta: "delta" }, permissions: { create: "TOKEN_CREATE", update: this.#canUpdate, delete: "TOKEN_DELETE" }, schemaVersion: "12.324" }, {inplace: false})); /** @inheritdoc */ static defineSchema() { return { _id: new fields.DocumentIdField(), name: new fields.StringField({required: true, blank: true, textSearch: true}), displayName: new fields.NumberField({required: true, initial: CONST.TOKEN_DISPLAY_MODES.NONE, choices: Object.values(CONST.TOKEN_DISPLAY_MODES), validationError: "must be a value in CONST.TOKEN_DISPLAY_MODES" }), actorId: new fields.ForeignDocumentField(documents.BaseActor, {idOnly: true}), actorLink: new fields.BooleanField(), delta: new ActorDeltaField(documents.BaseActorDelta), appendNumber: new fields.BooleanField(), prependAdjective: new fields.BooleanField(), width: new fields.NumberField({nullable: false, positive: true, initial: 1, step: 0.5, label: "Width"}), height: new fields.NumberField({nullable: false, positive: true, initial: 1, step: 0.5, label: "Height"}), texture: new TextureData({}, {initial: {src: () => this.DEFAULT_ICON, anchorX: 0.5, anchorY: 0.5, fit: "contain", alphaThreshold: 0.75}, wildcard: true}), hexagonalShape: new fields.NumberField({initial: CONST.TOKEN_HEXAGONAL_SHAPES.ELLIPSE_1, choices: Object.values(CONST.TOKEN_HEXAGONAL_SHAPES)}), x: new fields.NumberField({required: true, integer: true, nullable: false, initial: 0, label: "XCoord"}), y: new fields.NumberField({required: true, integer: true, nullable: false, initial: 0, label: "YCoord"}), elevation: new fields.NumberField({required: true, nullable: false, initial: 0}), sort: new fields.NumberField({required: true, integer: true, nullable: false, initial: 0}), locked: new fields.BooleanField(), lockRotation: new fields.BooleanField(), rotation: new fields.AngleField(), alpha: new fields.AlphaField(), hidden: new fields.BooleanField(), disposition: new fields.NumberField({required: true, choices: Object.values(CONST.TOKEN_DISPOSITIONS), initial: CONST.TOKEN_DISPOSITIONS.HOSTILE, validationError: "must be a value in CONST.TOKEN_DISPOSITIONS" }), displayBars: new fields.NumberField({required: true, choices: Object.values(CONST.TOKEN_DISPLAY_MODES), initial: CONST.TOKEN_DISPLAY_MODES.NONE, validationError: "must be a value in CONST.TOKEN_DISPLAY_MODES" }), bar1: new fields.SchemaField({ attribute: new fields.StringField({required: true, nullable: true, blank: false, initial: () => game?.system.primaryTokenAttribute || null}) }), bar2: new fields.SchemaField({ attribute: new fields.StringField({required: true, nullable: true, blank: false, initial: () => game?.system.secondaryTokenAttribute || null}) }), light: new fields.EmbeddedDataField(LightData), sight: new fields.SchemaField({ enabled: new fields.BooleanField({initial: data => Number(data?.sight?.range) > 0}), range: new fields.NumberField({required: true, nullable: true, min: 0, step: 0.01, initial: 0}), angle: new fields.AngleField({initial: 360, normalize: false}), visionMode: new fields.StringField({required: true, blank: false, initial: "basic", label: "TOKEN.VisionMode", hint: "TOKEN.VisionModeHint"}), color: new fields.ColorField({label: "TOKEN.VisionColor"}), attenuation: new fields.AlphaField({initial: 0.1, label: "TOKEN.VisionAttenuation", hint: "TOKEN.VisionAttenuationHint"}), brightness: new fields.NumberField({required: true, nullable: false, initial: 0, min: -1, max: 1, label: "TOKEN.VisionBrightness", hint: "TOKEN.VisionBrightnessHint"}), saturation: new fields.NumberField({required: true, nullable: false, initial: 0, min: -1, max: 1, label: "TOKEN.VisionSaturation", hint: "TOKEN.VisionSaturationHint"}), contrast: new fields.NumberField({required: true, nullable: false, initial: 0, min: -1, max: 1, label: "TOKEN.VisionContrast", hint: "TOKEN.VisionContrastHint"}) }), detectionModes: new fields.ArrayField(new fields.SchemaField({ id: new fields.StringField(), enabled: new fields.BooleanField({initial: true}), range: new fields.NumberField({required: true, min: 0, step: 0.01}) }), { validate: BaseToken.#validateDetectionModes }), occludable: new fields.SchemaField({ radius: new fields.NumberField({nullable: false, min: 0, step: 0.01, initial: 0}) }), ring: new fields.SchemaField({ enabled: new fields.BooleanField(), colors: new fields.SchemaField({ ring: new fields.ColorField(), background: new fields.ColorField() }), effects: new fields.NumberField({initial: 1, min: 0, max: 8388607, integer: true}), subject: new fields.SchemaField({ scale: new fields.NumberField({initial: 1, min: 0.5}), texture: new fields.FilePathField({categories: ["IMAGE"]}) }) }), /** @internal */ _regions: new fields.ArrayField(new fields.ForeignDocumentField(documents.BaseRegion, {idOnly: true})), flags: new fields.ObjectField() } } /** @override */ static LOCALIZATION_PREFIXES = ["TOKEN"]; /* -------------------------------------------- */ /** * Validate the structure of the detection modes array * @param {object[]} modes Configured detection modes * @throws An error if the array is invalid */ static #validateDetectionModes(modes) { const seen = new Set(); for ( const mode of modes ) { if ( mode.id === "" ) continue; if ( seen.has(mode.id) ) { throw new Error(`may not have more than one configured detection mode of type "${mode.id}"`); } seen.add(mode.id); } } /* -------------------------------------------- */ /** * The default icon used for newly created Token documents * @type {string} */ static DEFAULT_ICON = CONST.DEFAULT_TOKEN; /** * Is a user able to update an existing Token? * @private */ static #canUpdate(user, doc, data) { if ( user.isGM ) return true; // GM users can do anything if ( doc.actor ) { // You can update Tokens for Actors you control return doc.actor.canUserModify(user, "update", data); } return !!doc.actorId; // It would be good to harden this in the future } /** @override */ testUserPermission(user, permission, {exact=false} = {}) { if ( this.actor ) return this.actor.testUserPermission(user, permission, {exact}); else return super.testUserPermission(user, permission, {exact}); } /* -------------------------------------------- */ /** @inheritDoc */ updateSource(changes={}, options={}) { const diff = super.updateSource(changes, options); // A copy of the source data is taken for the _backup in updateSource. When this backup is applied as part of a dry- // run, if a child singleton embedded document was updated, the reference to its source is broken. We restore it // here. if ( options.dryRun && ("delta" in changes) ) this._source.delta = this.delta._source; return diff; } /* -------------------------------------------- */ /** @inheritdoc */ toObject(source=true) { const obj = super.toObject(source); obj.delta = this.delta ? this.delta.toObject(source) : null; return obj; } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** @inheritDoc */ static migrateData(data) { // Remember that any migrations defined here may also be required for the PrototypeToken model. /** * Migration of actorData field to ActorDelta document. * @deprecated since v11 */ if ( ("actorData" in data) && !("delta" in data) ) { data.delta = data.actorData; if ( "_id" in data ) data.delta._id = data._id; } return super.migrateData(data); } /* ----------------------------------------- */ /** @inheritdoc */ static shimData(data, options) { // Remember that any shims defined here may also be required for the PrototypeToken model. this._addDataFieldShim(data, "actorData", "delta", {value: data.delta, since: 11, until: 13}); this._addDataFieldShim(data, "effects", undefined, {value: [], since: 12, until: 14, warning: "TokenDocument#effects is deprecated in favor of using ActiveEffect" + " documents on the associated Actor"}); this._addDataFieldShim(data, "overlayEffect", undefined, {value: "", since: 12, until: 14, warning: "TokenDocument#overlayEffect is deprecated in favor of using" + " ActiveEffect documents on the associated Actor"}); return super.shimData(data, options); } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ get effects() { foundry.utils.logCompatibilityWarning("TokenDocument#effects is deprecated in favor of using ActiveEffect" + " documents on the associated Actor", {since: 12, until: 14, once: true}); return []; } /** * @deprecated since v12 * @ignore */ get overlayEffect() { foundry.utils.logCompatibilityWarning("TokenDocument#overlayEffect is deprecated in favor of using" + " ActiveEffect documents on the associated Actor", {since: 12, until: 14, once: true}); return ""; } } /* -------------------------------------------- */ /** * A special subclass of EmbeddedDocumentField which allows construction of the ActorDelta to be lazily evaluated. */ export class ActorDeltaField extends fields.EmbeddedDocumentField { /** @inheritdoc */ initialize(value, model, options = {}) { if ( !value ) return value; const descriptor = Object.getOwnPropertyDescriptor(model, this.name); if ( (descriptor === undefined) || (!descriptor.get && !descriptor.value) ) { return () => { const m = new this.model(value, {...options, parent: model, parentCollection: this.name}); Object.defineProperty(m, "schema", {value: this}); Object.defineProperty(model, this.name, { value: m, configurable: true, writable: true }); return m; }; } else if ( descriptor.get instanceof Function ) return descriptor.get; model[this.name]._initialize(options); return model[this.name]; } }